mikser-io 9.30.0 → 9.37.1
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/CLAUDE.md +35 -1
- package/docs/diagnostics.md +158 -1
- package/index.js +2 -0
- package/package.json +1 -1
- package/src/auth.js +13 -5
- package/src/builtin-tools.js +170 -0
- package/src/database/index.js +19 -2
- package/src/engine.js +104 -5
- package/src/logger.js +4 -1
- package/src/manifest.js +67 -1
- package/src/provenance.js +393 -0
- package/src/report.js +11 -0
- package/src/tools.js +133 -0
package/CLAUDE.md
CHANGED
|
@@ -198,6 +198,32 @@ brevity.
|
|
|
198
198
|
Strings produce a v9 migration error pointing at the new shape.
|
|
199
199
|
- `manager.js` — file watching (chokidar) and cron scheduling.
|
|
200
200
|
- `source.js` — `useSource` codifies the folder-of-files pattern.
|
|
201
|
+
- `tools.js` — tool registry. `registerTool(name, {description,
|
|
202
|
+
inputSchema}, handler)` / `toolNames()` / `toolSchema()` / `invokeTool()`,
|
|
203
|
+
stored on `runtime.tools`. There are TWO agent workflows — one speaking
|
|
204
|
+
MCP over HTTP, one running the CLI and reading its output — and every
|
|
205
|
+
tool used to live in the mcp plugin, reachable only through a session,
|
|
206
|
+
so the CLI agent saw a much smaller engine. The registry is substrate
|
|
207
|
+
and the transports are consumers: `--tools` / `--tool` dispatch through
|
|
208
|
+
it, mcp mirrors its registrations into it, and a tool registered by any
|
|
209
|
+
plugin reaches both surfaces with no per-tool CLI code. MCP keeps the
|
|
210
|
+
tools themselves, the transport, sessions, resources and prompts.
|
|
211
|
+
Dispatched at `onImport`, not `onLoaded` — the engine's own onLoaded is
|
|
212
|
+
registered during setup(), ahead of the plugins that register the tools.
|
|
213
|
+
- `builtin-tools.js` — the engine's own diagnostics as tools
|
|
214
|
+
(`mikser_explain`, `mikser_verify`, `mikser_build_report`). Registered
|
|
215
|
+
by the engine, not by mcp, so `--tool mikser_verify` works on a bare
|
|
216
|
+
engine exactly as `--verify` does. Schemas use a neutral
|
|
217
|
+
`{ type, required?, description? }` vocabulary; mcp converts to zod at
|
|
218
|
+
bind time, because the registry must not depend on one transport's
|
|
219
|
+
schema library.
|
|
220
|
+
- `provenance.js` — where a value was WRITTEN: source, field path, line,
|
|
221
|
+
column. Formats register (`registerProvenanceFormat` / `probeFormat`)
|
|
222
|
+
rather than being special-cased; yaml/json/front-matter use the `yaml`
|
|
223
|
+
parser's ranges, and anything without ranges uses the one-pass uuid
|
|
224
|
+
probe. Field paths are free; line/col is computed on demand and cached
|
|
225
|
+
in `mikser_provenance` against the entity's checksum, so a build pays
|
|
226
|
+
nothing.
|
|
201
227
|
- `routes.js` — HTTP route registry. Plugins mount on
|
|
202
228
|
`runtime.options.app` directly; the Express router stack has the
|
|
203
229
|
paths but not the intent (loopback-only? streaming?). So plugins
|
|
@@ -353,7 +379,15 @@ Test coverage: `test/unit/source-sweep.test.js`.
|
|
|
353
379
|
camelCase: `import { vector } from 'mikser-io-vector'`,
|
|
354
380
|
`import { renderHbs } from 'mikser-io'`. Consumer uses
|
|
355
381
|
`plugins: [vector({...})]` — never the bare string.
|
|
356
|
-
- **
|
|
382
|
+
- **Tool names**: the registry (`src/tools.js`) holds BARE names —
|
|
383
|
+
`explain`, `verify`, `sources`, `search`. The `mikser_` prefix is MCP's
|
|
384
|
+
namespacing, because its tool names are flat across every connected
|
|
385
|
+
server; `mikser-io-mcp` strips it when mirroring a registration into the
|
|
386
|
+
engine and re-adds it when binding into a session. `invokeTool` accepts
|
|
387
|
+
either form. On the CLI the prefix is stutter: `mikser --tool
|
|
388
|
+
mikser_explain` says mikser twice.
|
|
389
|
+
- **MCP tools** (as a client sees them): `mikser_<verb>` or
|
|
390
|
+
`mikser_<subsystem>_<verb>`:
|
|
357
391
|
`mikser_query_entities`, `mikser_read_entity`, `mikser_update_entity`,
|
|
358
392
|
`mikser_delete_entity`, `mikser_render`, `mikser_refs_inbound`,
|
|
359
393
|
`mikser_refs_outbound`, `mikser_refs_broken`, `mikser_refs_rename`,
|
package/docs/diagnostics.md
CHANGED
|
@@ -21,7 +21,9 @@ engine source, the entry point is missing and belongs on this page.
|
|
|
21
21
|
| Why does this page's output look stale? | [`runtime.manifest`](#runtimemanifest) |
|
|
22
22
|
| Two files seem to fight over one output | [`--explain`](#--explain-entity), [`--verify`](#--verify) |
|
|
23
23
|
| Which source file produced this built output? | [`runtime.manifest`](#runtimemanifest), `mikser_which` |
|
|
24
|
+
| Where was this VALUE written — file, field, line? | [`runtime.provenance`](#runtimeprovenance) |
|
|
24
25
|
| What would break if I changed this file? | [`runtime.manifest`](#runtimemanifest) `affectedBy` |
|
|
26
|
+
| I am an agent reading CLI output, not speaking MCP | [`--tools` / `--tool`](#the-two-agent-workflows) |
|
|
25
27
|
| Did my schema validate anything at all? | [`schemas.names()`](#schemasnames--schemaslookup) |
|
|
26
28
|
|
|
27
29
|
## Command line
|
|
@@ -285,12 +287,102 @@ stopped producing it.
|
|
|
285
287
|
| `-R, --resume` | continue from a previous interrupted run's journal; skips the filesystem scan |
|
|
286
288
|
| `-r, --clear` | clear state before running |
|
|
287
289
|
| `-d, --debug` / `-t, --trace` | raise log level; `trace` includes per-entity catalog writes |
|
|
290
|
+
| `--tools` | list the registered tools, then exit; `--json` for full schemas |
|
|
291
|
+
| `--tool <name>` | run one tool and print its result, then exit. `--tool-args '<json>'` supplies arguments |
|
|
288
292
|
|
|
289
293
|
`--force` composes with the unchanged-output check: it redoes the work
|
|
290
294
|
without touching files whose bytes did not move, so it is cheap to reach
|
|
291
295
|
for and its `unchanged` count tells you how much of the catalog was stale
|
|
292
296
|
by suspicion rather than in fact.
|
|
293
297
|
|
|
298
|
+
## The two agent workflows
|
|
299
|
+
|
|
300
|
+
There are two ways an agent drives mikser and they are equally real: one
|
|
301
|
+
speaks MCP over HTTP, the other runs the CLI and reads its output. Both
|
|
302
|
+
read the same tool registry, so neither sees a smaller engine than the
|
|
303
|
+
other.
|
|
304
|
+
|
|
305
|
+
```bash
|
|
306
|
+
npx mikser --tools
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
```bash
|
|
310
|
+
npx mikser --tool which --tool-args '{"destination":"/bg/index.html","text":"Контакти"}'
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Tool names are **bare** here — `explain`, `verify`, `sources`, `which`. The
|
|
314
|
+
`mikser_` prefix belongs to MCP, where tool names share one flat namespace
|
|
315
|
+
across every server a client has connected to and an unprefixed `verify`
|
|
316
|
+
would collide with anyone else's. The engine has no such problem, and
|
|
317
|
+
`mikser --tool mikser_explain` says mikser twice. The prefix is added at
|
|
318
|
+
the session boundary, so an MCP client sees exactly the names it always
|
|
319
|
+
did. Either form is accepted on the CLI, because an agent reading MCP
|
|
320
|
+
documentation should not have to know which surface stripped what.
|
|
321
|
+
|
|
322
|
+
A report-and-exit run — `--explain`, `--verify`, `--tools`, `--tool` —
|
|
323
|
+
never wipes the cache, even when the config or the schema version has
|
|
324
|
+
moved. Wiping for one destroys the state it was asked to describe and
|
|
325
|
+
then answers from the empty result as though that were the answer;
|
|
326
|
+
measured, one `--tool mikser_query_entities` after an edit to
|
|
327
|
+
`mikser.config.js` dropped 521 entities and replied `total: 0`. The
|
|
328
|
+
staleness is still reported, loudly. A build still wipes, because a build
|
|
329
|
+
is what repopulates.
|
|
330
|
+
|
|
331
|
+
An empty catalog is said out loud too, before the answer: every tool
|
|
332
|
+
replies `null` / `total: 0` / "no render claims this destination" when
|
|
333
|
+
nothing has been built, and all of those read as "the thing you asked
|
|
334
|
+
about does not exist".
|
|
335
|
+
|
|
336
|
+
`--tools` lists what is registered, with `--json` for the full schemas.
|
|
337
|
+
`--tool` runs one and prints its result; stdout carries only that result
|
|
338
|
+
— the banner and every log line move to stderr, as under `--json` — so
|
|
339
|
+
piping into `jq` works. Exit status is `0` on success, `1` when the tool
|
|
340
|
+
itself reported an error, and `3` when the tool does not exist or its
|
|
341
|
+
arguments are not valid JSON, because an agent reading CLI output has
|
|
342
|
+
only the status to branch on.
|
|
343
|
+
|
|
344
|
+
The registry lives in the engine (`registerTool` / `toolNames` /
|
|
345
|
+
`invokeTool`), and the transports are consumers of it. That is what makes
|
|
346
|
+
the parity hold rather than decay: a tool registered through
|
|
347
|
+
`runtime.options.mcp` — by mcp, layouts, vector, or one written next week
|
|
348
|
+
— is reachable from both surfaces the moment it exists, with no per-tool
|
|
349
|
+
CLI code and no second list to keep in step. The mcp plugin still owns
|
|
350
|
+
the tools themselves, the transport, sessions, resources and prompts; the
|
|
351
|
+
engine knows only a name, a description, an input schema and a function.
|
|
352
|
+
|
|
353
|
+
The mirroring runs both ways. mcp's registrations flow into the engine's
|
|
354
|
+
registry, so everything it registers is reachable from `--tool`; and a
|
|
355
|
+
session binds the engine's own registrations too, converting their schema
|
|
356
|
+
to zod at bind time. The engine declares an input as
|
|
357
|
+
`{ name: { type, required?, description? } }` rather than as zod, because
|
|
358
|
+
the registry is transport agnostic and should not depend on one
|
|
359
|
+
transport's schema library. The vocabulary covers string / number /
|
|
360
|
+
boolean / array and stops there — anything richer would be a schema
|
|
361
|
+
language, which the engine has no business owning. A tool needing real
|
|
362
|
+
validation registers through `runtime.options.mcp` with zod, which is
|
|
363
|
+
what every tool in that plugin does.
|
|
364
|
+
|
|
365
|
+
**`explain`, `verify`, `sources` and `build_report` are the engine's own**,
|
|
366
|
+
registered in `src/builtin-tools.js` rather than by a plugin. `sources`
|
|
367
|
+
is the reverse lookup — what produced this destination, each source
|
|
368
|
+
tagged with how it got there — reading the `refClosure` through
|
|
369
|
+
`manifest.sourcesOf`. Locating a string *inside* those sources stays in
|
|
370
|
+
`mikser-io-mcp`'s `which`, which reports each occurrence's line and
|
|
371
|
+
whether the string begins it: a declaration usually does and a use
|
|
372
|
+
usually does not, which separates the two in any text format without a
|
|
373
|
+
per-language grammar. That is what makes `--tool mikser_verify` work on a bare engine,
|
|
374
|
+
the same as `--verify` — before, the engine's diagnostics needed an agent
|
|
375
|
+
surface configured to be reachable as tools, which is backwards.
|
|
376
|
+
|
|
377
|
+
`--explain` and `--verify` stay as flags rather than becoming `--tool`
|
|
378
|
+
invocations. They are not a second implementation: the flag and the tool
|
|
379
|
+
both call `explain()` and `manifest.verify()`. Routing the flag through
|
|
380
|
+
the tool would add a JSON serialize-and-reparse for nothing. What the
|
|
381
|
+
flags carry that the tool cannot is presentation and exit status —
|
|
382
|
+
`formatExplain`'s aligned columns are for a person, and `--verify` exits
|
|
383
|
+
`0` / `1` / `2` for OK / WARN / FAIL, a CI gate contract that `--tool`'s
|
|
384
|
+
`0` / `1` cannot express without lying about one of the three.
|
|
385
|
+
|
|
294
386
|
## Over a transport
|
|
295
387
|
|
|
296
388
|
Everything above assumes a shell on the machine. Three of these questions
|
|
@@ -306,7 +398,10 @@ reporting occurrences per destination; `mikser_read_output` reads the
|
|
|
306
398
|
bytes currently on disk for a destination, which is a different question
|
|
307
399
|
from what the catalog or the manifest says should be there;
|
|
308
400
|
`mikser_which` goes the other way, from a built destination back to the
|
|
309
|
-
source that produced it
|
|
401
|
+
source that produced it — reading recorded provenance where it exists and
|
|
402
|
+
labelling each answer `meta-field` / `source-content` (recorded) or `scan`
|
|
403
|
+
(not), because a recorded answer and a guessed one warrant different trust;
|
|
404
|
+
and
|
|
310
405
|
`mikser_update_entity({ dryRun: true })` reports the blast radius of an
|
|
311
406
|
edit before making it.
|
|
312
407
|
|
|
@@ -438,6 +533,8 @@ What was rendered and whether it needs redoing.
|
|
|
438
533
|
| `recordedHashes()` | the dep-hashes dependents last saw |
|
|
439
534
|
| `queryAffected(mutated)` | which query-dependent snapshots this mutation hits |
|
|
440
535
|
| `snapshotsAt(destination)` | every snapshot claiming a destination — the reverse of `snapshotsFor`, and the way back from a built file to what produced it |
|
|
536
|
+
| `sourcesBehind(snapshot)` | the source entities that fed one render, each with `via` naming HOW it got there (layout, partial, ref, or the recorded query it matched) |
|
|
537
|
+
| `sourcesOf(destination)` | the same across every entity claiming a destination, unioned — what `mikser_which` answers from |
|
|
441
538
|
| `affectedBy(entity)` | which destinations would re-render if this entity changed, each with the same `reason` the build report uses |
|
|
442
539
|
| `verify({outputFolder})` | `{ verdict, missing, mismatched, unverifiable, orphaned, collisions }` — what `--verify` reports; pure, no mutations |
|
|
443
540
|
| `collisions()` | destinations claimed by more than one entity, with the ids claiming each |
|
|
@@ -449,6 +546,15 @@ destinations and a caller asking "what happened to this?" does not know
|
|
|
449
546
|
them in advance — which is exactly the position you are in when a page
|
|
450
547
|
did not change and you want to know why.
|
|
451
548
|
|
|
549
|
+
`sourcesBehind` is the reverse of everything else here, and it lives in the
|
|
550
|
+
engine because the `refClosure` IS the engine's record of what a render
|
|
551
|
+
consumed — which is what makes the answer authoritative rather than a
|
|
552
|
+
guess at which file might hold something. A bundle assembled from
|
|
553
|
+
`findEntities({ collection: 'styles' })` records that query, so re-running
|
|
554
|
+
it returns exactly the parts that went in. A query whose filter could not
|
|
555
|
+
be serialized names no members: it invalidates on any mutation, which is
|
|
556
|
+
not the same as "every entity fed this render".
|
|
557
|
+
|
|
452
558
|
`affectedBy(entity)` answers the same question one step earlier: *before*
|
|
453
559
|
editing a shared file, which outputs does this reach? It runs the real
|
|
454
560
|
`skipDecision` against each candidate rather than reimplementing the
|
|
@@ -457,6 +563,57 @@ cannot model is how the entity's own frontmatter would change — that is
|
|
|
457
563
|
parsed during import, so an edit that moves `meta.layout` moves the
|
|
458
564
|
destination too, and this does not see it.
|
|
459
565
|
|
|
566
|
+
### `runtime.provenance`
|
|
567
|
+
|
|
568
|
+
Where a value was **written** — source file, field path, line and column.
|
|
569
|
+
|
|
570
|
+
| Method | Answers |
|
|
571
|
+
| --- | --- |
|
|
572
|
+
| `positionsFor(entity)` | `{ 'items[2].label': { line, col }, … }` for every leaf of the entity's meta |
|
|
573
|
+
| `locate(entity, fieldPath)` | one position, or null |
|
|
574
|
+
| `forget(id)` | drop a cached entry |
|
|
575
|
+
|
|
576
|
+
The field PATH costs nothing — it comes from walking `entity.meta`, which is
|
|
577
|
+
already in memory, so it is always available. Line and column need one parse
|
|
578
|
+
of the raw source, and that happens **on demand**, never during a build. The
|
|
579
|
+
result is cached in `mikser_provenance` against the entity's checksum, so the
|
|
580
|
+
first question about a file pays one parse and every later one pays nothing
|
|
581
|
+
until the file changes. A build pays nothing at all.
|
|
582
|
+
|
|
583
|
+
Formats are handled by registered handlers rather than by a chain of
|
|
584
|
+
`if (extension === …)`:
|
|
585
|
+
|
|
586
|
+
| Format | How the position is found |
|
|
587
|
+
| --- | --- |
|
|
588
|
+
| yaml, json | `YAML.parseDocument` reports a range on every node; YAML is a superset of JSON, so one parse covers both |
|
|
589
|
+
| front matter | the block is parsed the same way, with the line offset added back |
|
|
590
|
+
| archieml | no ranges from the parser — the uuid probe below; registered by `mikser-io-aml` |
|
|
591
|
+
| csv rows | synthetic entities with no file of their own; the row is located in the PARENT csv by its key; registered by `mikser-io-csv` |
|
|
592
|
+
| remote (`gdrive://`, `github://`, …) | read through `readEntityContent`, so the provider dispatch applies as everywhere else |
|
|
593
|
+
| assets | none — meta is synthesized, so no source position exists |
|
|
594
|
+
|
|
595
|
+
`registerProvenanceFormat(name, { test, positions })` adds one; `probeFormat(name,
|
|
596
|
+
{ test, parse })` is the shortcut for a parser that reports no ranges. Later
|
|
597
|
+
registrations win. A format that ships in its own package registers there — the
|
|
598
|
+
engine has no business knowing archieml exists.
|
|
599
|
+
|
|
600
|
+
**The probe**, for a parser with no ranges: substitute a unique token for every
|
|
601
|
+
value, re-parse **once** with the format's own parser, and read each position
|
|
602
|
+
off wherever its token actually landed. This is mikser 4.x's `plugins/guide.js`
|
|
603
|
+
mechanism with the thing that killed it removed — it re-parsed once per FIELD
|
|
604
|
+
and needed worker processes and a disk cache to survive that. Positions are
|
|
605
|
+
read from where tokens landed rather than from assuming a substitution matched
|
|
606
|
+
the intended field, so a repeated value cannot produce a confidently wrong
|
|
607
|
+
answer. There is no regex over the value, so its size is irrelevant.
|
|
608
|
+
|
|
609
|
+
Nothing is injected into the output. The predecessor printed provenance into
|
|
610
|
+
HTML comments for a browser script to turn into tooltips, which is why it was
|
|
611
|
+
only ever safe in development — it changed the bytes that ship. The consumer
|
|
612
|
+
here is an editing agent, which never looks at rendered bytes, so the answer is
|
|
613
|
+
returned as data instead: `mikser_which` for "what produced this output", and
|
|
614
|
+
`mikser_read_entity({ include: ["positions"] })` for "where is this value
|
|
615
|
+
written".
|
|
616
|
+
|
|
460
617
|
### `layouts.inspect()`
|
|
461
618
|
|
|
462
619
|
Exposed by `mikser-io-layouts` at `runtime.options.layouts.inspect(id)`.
|
package/index.js
CHANGED
|
@@ -13,6 +13,8 @@ export * from './src/journal.js'
|
|
|
13
13
|
export * from './src/catalog.js'
|
|
14
14
|
export * from './src/refs.js'
|
|
15
15
|
export * from './src/manifest.js'
|
|
16
|
+
export * from './src/provenance.js'
|
|
17
|
+
export * from './src/tools.js'
|
|
16
18
|
export * from './src/track.js'
|
|
17
19
|
export * from './src/subscriptions.js'
|
|
18
20
|
export * from './src/config.js'
|
package/package.json
CHANGED
package/src/auth.js
CHANGED
|
@@ -138,14 +138,22 @@ export async function authorize(req, verifier, { allowRemote = false, trustLoopb
|
|
|
138
138
|
// Absent (every verifier before this existed), the answer is
|
|
139
139
|
// today's: 401 invalid_token, which is right for the common case
|
|
140
140
|
// and is what a client needs in order to refresh at all.
|
|
141
|
-
|
|
141
|
+
// Named fields rather than a spread: a refinement must not be
|
|
142
|
+
// able to reach `ok` or `principal`, and listing what may cross
|
|
143
|
+
// is how that stays true when someone adds a field later.
|
|
144
|
+
// `scope` is load-bearing on an insufficient_scope challenge —
|
|
145
|
+
// RFC 6750 §3.1 puts the capability the caller lacks in it, and
|
|
146
|
+
// without it the client is told it is unauthorized but not for
|
|
147
|
+
// what.
|
|
148
|
+
const { status, code, description, scope } = verifier.rejectionFor?.(req) ?? {}
|
|
142
149
|
return {
|
|
143
150
|
ok: false,
|
|
144
|
-
status:
|
|
151
|
+
status: status ?? 401,
|
|
145
152
|
reason: 'invalid',
|
|
146
|
-
code:
|
|
147
|
-
description
|
|
148
|
-
|
|
153
|
+
code: code ?? 'invalid_token',
|
|
154
|
+
description,
|
|
155
|
+
scope,
|
|
156
|
+
error: description ?? 'Invalid credential',
|
|
149
157
|
}
|
|
150
158
|
}
|
|
151
159
|
// Nothing presented. No `code`: RFC 6750 §3.1 says a challenge to a
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// The engine's own diagnostics, registered as tools.
|
|
2
|
+
//
|
|
3
|
+
// `explain()`, `manifest.verify()` and `buildReport()` are engine functions,
|
|
4
|
+
// but the tools wrapping them were registered by the mcp plugin — so
|
|
5
|
+
// `--tool mikser_explain` needed an agent surface configured while `--explain`
|
|
6
|
+
// did not. That is backwards for the engine's own diagnostics, and it is why
|
|
7
|
+
// `--verify` and `--explain` could not simply become `--tool` invocations.
|
|
8
|
+
//
|
|
9
|
+
// Registering them here fixes the direction: they exist on a bare engine, the
|
|
10
|
+
// CLI flags become renderings of them rather than parallel implementations,
|
|
11
|
+
// and the mcp plugin exposes them over a session without owning them.
|
|
12
|
+
//
|
|
13
|
+
// Names are BARE — `explain`, not `mikser_explain`. The prefix exists because
|
|
14
|
+
// MCP tool names share one flat namespace across every server a client has
|
|
15
|
+
// connected to, so an unprefixed `verify` would collide with any other server
|
|
16
|
+
// offering one. That is the protocol's constraint, not the engine's: on the CLI
|
|
17
|
+
// `mikser --tool mikser_explain` says mikser twice. So the registry holds the
|
|
18
|
+
// bare name and `mikser-io-mcp` adds the prefix when it binds a tool into a
|
|
19
|
+
// session — the boundary where it is actually needed.
|
|
20
|
+
//
|
|
21
|
+
// Schemas are declared in a neutral vocabulary rather than zod, because the
|
|
22
|
+
// engine does not depend on zod and should not: the registry is transport
|
|
23
|
+
// agnostic. `mikser-io-mcp` converts these to zod at bind time. The vocabulary
|
|
24
|
+
// is deliberately small — a name, a type, whether it is required, a
|
|
25
|
+
// description — since anything richer would be a schema language, and the
|
|
26
|
+
// engine has no business owning one.
|
|
27
|
+
|
|
28
|
+
import runtime from './runtime.js'
|
|
29
|
+
import { registerTool } from './tools.js'
|
|
30
|
+
import { buildReport, cycleHistory } from './report.js'
|
|
31
|
+
|
|
32
|
+
// The MCP content envelope, so a CLI caller and a session caller see the same
|
|
33
|
+
// bytes. Built here rather than imported from the plugin, because the plugin
|
|
34
|
+
// is the thing that must not be required.
|
|
35
|
+
const ok = (data) => ({
|
|
36
|
+
content: [{ type: 'text', text: typeof data === 'string' ? data : JSON.stringify(data, null, 2) }],
|
|
37
|
+
})
|
|
38
|
+
const fail = (message) => ({ isError: true, content: [{ type: 'text', text: message }] })
|
|
39
|
+
|
|
40
|
+
export function registerBuiltinTools() {
|
|
41
|
+
registerTool(
|
|
42
|
+
'explain',
|
|
43
|
+
{
|
|
44
|
+
description:
|
|
45
|
+
'Explain ONE entity: which layout claimed it and why, its destination, which inputs moved since it '
|
|
46
|
+
+ 'last rendered, every dependency edge with what it resolved to, whether its last render attempt '
|
|
47
|
+
+ 'threw, and a verdict on whether a build would re-render it. Accepts an id, a meta.href, or an id '
|
|
48
|
+
+ 'without its extension. The first thing to reach for when a page will not rebuild and nothing says '
|
|
49
|
+
+ 'why. Compares the CATALOG against the manifest, so an edit not yet imported reports as "source '
|
|
50
|
+
+ 'differs from the catalog".',
|
|
51
|
+
inputSchema: {
|
|
52
|
+
reference: { type: 'string', required: true,
|
|
53
|
+
description: 'Entity id, meta.href, or id without its extension.' },
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
async ({ reference }) => {
|
|
57
|
+
try {
|
|
58
|
+
// Imported here rather than at module scope: explain.js pulls
|
|
59
|
+
// in the formatter and the manifest, and a build that never
|
|
60
|
+
// asks should not pay for them.
|
|
61
|
+
const { explain } = await import('./explain.js')
|
|
62
|
+
// found:false is an answer, not a failure — it carries a hint
|
|
63
|
+
// about why nothing matched, which is the useful half when a
|
|
64
|
+
// caller has guessed at an id.
|
|
65
|
+
return ok(await explain(reference))
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return fail(err.message)
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
registerTool(
|
|
73
|
+
'verify',
|
|
74
|
+
{
|
|
75
|
+
description:
|
|
76
|
+
'Check the output folder against what the manifest recorded: files missing, files whose bytes no '
|
|
77
|
+
+ 'longer match, snapshots with no recorded hash, files on disk no snapshot claims, and destinations '
|
|
78
|
+
+ 'claimed by more than one entity. Answers "is what is deployed what mikser thinks it produced". '
|
|
79
|
+
+ 'Does not build or write anything.',
|
|
80
|
+
inputSchema: {},
|
|
81
|
+
},
|
|
82
|
+
async () => {
|
|
83
|
+
try {
|
|
84
|
+
if (!runtime.manifest?.verify) return fail('No manifest available — nothing to verify against')
|
|
85
|
+
// The verdict comes FROM the manifest, which is the single
|
|
86
|
+
// place that rule lives. Three consumers deriving it from
|
|
87
|
+
// counts is how one of them silently stopped counting
|
|
88
|
+
// collisions.
|
|
89
|
+
return ok({
|
|
90
|
+
snapshots: runtime.manifest.size?.() ?? null,
|
|
91
|
+
...(await runtime.manifest.verify()),
|
|
92
|
+
})
|
|
93
|
+
} catch (err) {
|
|
94
|
+
return fail(err.message)
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
registerTool(
|
|
100
|
+
'sources',
|
|
101
|
+
{
|
|
102
|
+
description:
|
|
103
|
+
'Reverse lookup: what SOURCE entities produced this built destination, and how each one got there. '
|
|
104
|
+
+ 'Read from the engine\'s refClosure — its own record of what a render consumed — so a bundle '
|
|
105
|
+
+ 'assembled from a catalog query resolves to the actual parts that went in, each tagged with the '
|
|
106
|
+
+ 'route that reached it (layout, partial, ref, or the recorded query it matched).\n\n'
|
|
107
|
+
+ 'More than one claimant means a destination collision; the sources of all of them are unioned, and '
|
|
108
|
+
+ '`explain` names the competitors. To locate a STRING or a CSS selector inside these sources, '
|
|
109
|
+
+ 'use `which` (mikser-io-mcp), which answers this question and then searches the answer.',
|
|
110
|
+
inputSchema: {
|
|
111
|
+
destination: { type: 'string', required: true,
|
|
112
|
+
description: 'Output-relative destination, e.g. "/bg/styles/site.css".' },
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
async ({ destination }) => {
|
|
116
|
+
try {
|
|
117
|
+
if (!destination) return fail('destination is required')
|
|
118
|
+
if (!runtime.manifest?.snapshotsAt) {
|
|
119
|
+
return fail('No manifest available — nothing has been rendered yet.')
|
|
120
|
+
}
|
|
121
|
+
const { sourcesOf } = await import('./manifest.js')
|
|
122
|
+
const claimants = runtime.manifest.snapshotsAt(destination).map(snap => snap.id)
|
|
123
|
+
const sources = await sourcesOf(destination)
|
|
124
|
+
return ok({
|
|
125
|
+
destination,
|
|
126
|
+
claimants,
|
|
127
|
+
sources,
|
|
128
|
+
count: sources.length,
|
|
129
|
+
// An empty list reads as "nothing produced this", when the
|
|
130
|
+
// usual cause is a file COPIED there by the files/shares/
|
|
131
|
+
// data plugins, which write without a render snapshot.
|
|
132
|
+
...(claimants.length ? {} : {
|
|
133
|
+
hint: 'No render claims this destination. It may be a file copied there without a render '
|
|
134
|
+
+ 'snapshot, or the path may be wrong.',
|
|
135
|
+
}),
|
|
136
|
+
...(claimants.length > 1 ? {
|
|
137
|
+
contested: 'More than one entity renders here — see the `explain` tool. The sources below are '
|
|
138
|
+
+ 'the union of what all of them consumed.',
|
|
139
|
+
} : {}),
|
|
140
|
+
})
|
|
141
|
+
} catch (err) {
|
|
142
|
+
return fail(err.message)
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
registerTool(
|
|
148
|
+
'build_report',
|
|
149
|
+
{
|
|
150
|
+
description:
|
|
151
|
+
'What a build cycle did, and why: entities rendered (each with a reason — inputs-changed, '
|
|
152
|
+
+ 'ref-changed, query-matched, retry-failed — and the detail behind it), skipped, rendered-but-'
|
|
153
|
+
+ 'byte-identical, renders that threw, warnings, and a count of entities gated at import. Each '
|
|
154
|
+
+ 'report carries the cycleId it describes. Pass cycles: N for the last N FINISHED cycles, newest '
|
|
155
|
+
+ 'first; history keeps the last 10 of this process.',
|
|
156
|
+
inputSchema: {
|
|
157
|
+
cycles: { type: 'number',
|
|
158
|
+
description: 'How many finished cycles to return, newest first. Omit for the current one.' },
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
async ({ cycles }) => {
|
|
162
|
+
try {
|
|
163
|
+
if (cycles) return ok({ reports: cycleHistory(cycles) })
|
|
164
|
+
return ok(buildReport())
|
|
165
|
+
} catch (err) {
|
|
166
|
+
return fail(err.message)
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
)
|
|
170
|
+
}
|
package/src/database/index.js
CHANGED
|
@@ -28,6 +28,7 @@ import path from 'node:path'
|
|
|
28
28
|
import { mkdirSync, unlinkSync, existsSync } from 'node:fs'
|
|
29
29
|
import Database from 'better-sqlite3'
|
|
30
30
|
import runtime from '../runtime.js'
|
|
31
|
+
import { isReportOnlyRun } from '../tools.js'
|
|
31
32
|
import { onLoaded } from '../lifecycle.js'
|
|
32
33
|
import packageInfo from '../../package.json' with { type: 'json' }
|
|
33
34
|
|
|
@@ -288,8 +289,24 @@ export function createSqliteDatabase({
|
|
|
288
289
|
const currentConfig = runtime.options.configChecksum ?? null
|
|
289
290
|
const configChanged = Boolean(recordedConfig && currentConfig && recordedConfig !== currentConfig)
|
|
290
291
|
|
|
292
|
+
// Report-and-exit invocations never rebuild, so wiping for them
|
|
293
|
+
// destroys the very state they were asked to describe — and then they
|
|
294
|
+
// answer from the empty cache as though that were the answer. Measured:
|
|
295
|
+
// one `--tool mikser_query_entities` after a config edit dropped 521
|
|
296
|
+
// entities and replied `total: 0`, which reads as "there are none".
|
|
297
|
+
//
|
|
298
|
+
// The staleness is real and still worth saying out loud; what is wrong
|
|
299
|
+
// is doing something irreversible about it on a read.
|
|
300
|
+
const reportOnly = isReportOnlyRun()
|
|
301
|
+
|
|
291
302
|
let upgradedFromVersion = null
|
|
292
|
-
if (
|
|
303
|
+
if (reportOnly && ((recorded && recorded !== version) || configChanged)) {
|
|
304
|
+
logger?.warn(
|
|
305
|
+
'The cache is stale (%s changed since it was written) and this is a read-only run, '
|
|
306
|
+
+ 'so it was NOT wiped — the answer below describes the last build, which may not '
|
|
307
|
+
+ 'match your sources. Run a build to refresh it.',
|
|
308
|
+
configChanged ? 'config' : 'schema version')
|
|
309
|
+
} else if (configChanged && !(recorded && recorded !== version)) {
|
|
293
310
|
logger?.warn(
|
|
294
311
|
'Config changed since the last run. Wiping the cache and rebuilding from sources ' +
|
|
295
312
|
'(files are the source of truth — no source data is affected). Note this tracks the ' +
|
|
@@ -297,7 +314,7 @@ export function createSqliteDatabase({
|
|
|
297
314
|
runtime.options.config,
|
|
298
315
|
)
|
|
299
316
|
}
|
|
300
|
-
if ((recorded && recorded !== version) || configChanged) {
|
|
317
|
+
if (!reportOnly && ((recorded && recorded !== version) || configChanged)) {
|
|
301
318
|
// Schema mismatch on upgrade or downgrade. Per ADR-0002 the
|
|
302
319
|
// files on disk are the source of truth and this database
|
|
303
320
|
// is a derived cache, so the right behavior is to wipe the
|