mikser-io 9.99.0 → 10.0.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/app.js CHANGED
@@ -68,7 +68,13 @@ function locate(argv) {
68
68
  // was started without the flag. So the contract has to travel
69
69
  // with the request or it is not honoured at all.
70
70
  json: has('--json'),
71
- renderPresets: has('--render-presets') ? (value('--render-presets') ?? true) : undefined }
71
+ // Travels for the same reason clear and renderPresets do: it
72
+ // changes what the CYCLE does, and the instance was started
73
+ // without it. A forwarded --force silently did not force — it
74
+ // rebuilt whatever the gates let through, which on a settled tree
75
+ // is nothing, and a caller asking for a full re-render got a no-op
76
+ // reported as success.
77
+ force: has('--force', '-f') }
72
78
 
73
79
  return {
74
80
  longRunning,
@@ -171,6 +171,17 @@ cycle being reported.
171
171
  }
172
172
  ```
173
173
 
174
+ `plugins` breaks a phase down by who spent it:
175
+
176
+ ```
177
+ "plugins": [ { "phase": "finalized", "plugin": "lighthouse", "ms": 6381.1, "calls": 1 } ]
178
+ ```
179
+
180
+ Inside the phases, not beside them — a plugin's time is part of its phase's, so
181
+ the phase totals still add up to `total`. Without it, `finalized` is one number
182
+ covering the reference check, schemas, lint and any audit, and attributing a
183
+ regression to one of them takes a separate measurement each.
184
+
174
185
  Ordered slowest first, so a diff between two versions leads with what moved.
175
186
  This exists because the report said what was *done* and never what it *took*: a
176
187
  preset fan-out that scanned the whole catalog every cycle shipped and ran for
package/index.js CHANGED
@@ -5,6 +5,11 @@ export * from './src/auth.js'
5
5
  export * from './src/roles.js'
6
6
  export * from './src/inventory.js'
7
7
  export * from './src/report.js'
8
+ export * from './src/cli.js'
9
+ // What one plugin offers another. A plugin publishes an API under a name
10
+ // (provideService) or adds to an extension point (contribute); consumers ask
11
+ // core, never a sibling. Replaces reaching into runtime.options.<plugin>.
12
+ export * from './src/services.js'
8
13
  // The diagnostics behind --explain. Exported so a transport — the MCP tool
9
14
  // surface, the api plugin's routes — can serve the same structured report the
10
15
  // CLI formats, rather than each one reimplementing the question.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.99.0",
3
+ "version": "10.0.0",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
package/src/cli.js ADDED
@@ -0,0 +1,173 @@
1
+ // CLI options a plugin adds.
2
+ //
3
+ // An option is a contract between a person and a build, and a plugin is where
4
+ // half the build lives — so a plugin that cannot add one has to be reached
5
+ // some other way: an environment variable, a config key that means "do the
6
+ // expensive thing", a wrapper script. All three were tried downstream and all
7
+ // three are worse in the same way: they are invisible to `--help`, they cannot
8
+ // be refused when misspelled, and they do not appear beside the checks they
9
+ // belong next to.
10
+ //
11
+ // The reason it was not possible is an ordering fact rather than a decision.
12
+ // The engine parses argv in onInitialize, and the CONFIG — which is where the
13
+ // plugins are named — is not read until onLoad. At the moment the first parse
14
+ // runs, the engine genuinely does not know what options exist.
15
+ //
16
+ // So the parse happens in two stages, and the second one is the real one:
17
+ //
18
+ // 1. onInitialize — core's own options, tolerating unknowns, because the
19
+ // table is knowingly incomplete. Enough to find the config and the
20
+ // folders.
21
+ // 2. after the load phase — every plugin has been constructed and has had
22
+ // its chance to declare, so the table is complete. argv is parsed again
23
+ // against it, and NOW an unknown option is an error.
24
+ //
25
+ // The refusal that has to survive this is the one from 9.81.0: a misspelled
26
+ // flag must never be silently ignored. It is not weakened, only moved to the
27
+ // point where "unknown" can actually be decided.
28
+
29
+ import runtime from './runtime.js'
30
+
31
+ // What plugins declared, for the message when something is not recognised and
32
+ // for anyone asking what a build's option table actually contains.
33
+ const declared = new Map()
34
+
35
+ // Declare a CLI option from inside a plugin.
36
+ //
37
+ // Call it while the plugin is being constructed — that is during the load
38
+ // phase, before the second parse. Declaring later is not an error but the
39
+ // option will not be parsed, so it says so rather than doing nothing.
40
+ //
41
+ // cliOption('--lighthouse', 'audit the built pages with Lighthouse, and exit 0')
42
+ //
43
+ // Reads back from `runtime.options` under commander's usual camel-cased name —
44
+ // but NOT at construction time. Declaring happens during the load phase and
45
+ // the table is not parsed until after it, so an option read while the plugin
46
+ // is being built is always undefined. Read it in a hook: onLoaded and later
47
+ // all run after stage two. documents() read its folder at construction first,
48
+ // and `--documents content` silently did nothing.
49
+ export function cliOption(flags, description, defaultValue) {
50
+ // No CLI at all — a plugin constructed by a test harness, or embedded
51
+ // programmatically through setup({ ... }) rather than run from a terminal.
52
+ // There is nothing to register the option on and nothing about that is a
53
+ // mistake, so it returns quietly. Throwing here made every plugin that
54
+ // declares an option unusable in its own unit tests, which is how this was
55
+ // found.
56
+ //
57
+ // Declaring LATE is still an error — see below — because that one really
58
+ // is a mistake: the option would never be parsed and the flag would look
59
+ // ignored.
60
+ const commander = runtime.engine?.commander
61
+ if (!commander) return undefined
62
+ if (runtime.engine.cliSealed) {
63
+ throw new Error(
64
+ `cliOption(${JSON.stringify(flags)}): the option table was already parsed. `
65
+ + 'Declare options while the plugin is constructed (the load phase), not afterwards — '
66
+ + 'a later one would never be read and the flag would look ignored.')
67
+ }
68
+ if (declared.has(flags)) return declared.get(flags)
69
+ const option = defaultValue === undefined
70
+ ? commander.option(flags, description)
71
+ : commander.option(flags, description, defaultValue)
72
+ declared.set(flags, option)
73
+ return option
74
+ }
75
+
76
+ // Every option a plugin added, in declaration order.
77
+ export function pluginCliOptions() {
78
+ return [...declared.keys()]
79
+ }
80
+
81
+ // Stage two: the table is complete, so parse for real.
82
+ //
83
+ // Called once, after the load phase. Merges what the complete table yields
84
+ // over what stage one produced — a plugin's option lands here for the first
85
+ // time, and core's own options parse identically both times.
86
+ export function completeCliParse() {
87
+ const commander = runtime.engine?.commander
88
+ if (!commander || runtime.engine.cliSealed) return
89
+ runtime.engine.cliSealed = true
90
+
91
+ // Always, even when no plugin declared anything.
92
+ //
93
+ // Stage one tolerates an unknown option AND the excess argument it then
94
+ // looks like, so it cannot be the stage that judges argv — left to it,
95
+ // `mikser --bogus-flag` came back "too many arguments. Expected 0
96
+ // arguments but got 1", which is commander describing its own internal
97
+ // reading rather than the mistake a person made. Skipping stage two when
98
+ // no plugin declared would leave exactly that message on most builds.
99
+ //
100
+ // Strict here: every option any part of this build understands is
101
+ // registered, so anything left over is a misspelling, and saying which one
102
+ // is the whole point of the refusal.
103
+ commander.allowUnknownOption(false).allowExcessArguments(false)
104
+
105
+ // Help, now that the table is complete — including every option the
106
+ // project's own plugins declared, which is the whole reason it waited.
107
+ if (runtime.engine.helpRequested) {
108
+ commander.outputHelp()
109
+ process.exit(0)
110
+ }
111
+
112
+ commander.parse(process.argv)
113
+
114
+ // ONLY the options a plugin declared.
115
+ //
116
+ // Assigning the whole opts() blob put core's own options back to their
117
+ // parsed values — and by this point the engine has already NORMALISED
118
+ // several of them. `--working-folder` defaults to './' and onInitialize
119
+ // resolves it to an absolute path; re-assigning made it './' again, after
120
+ // the load phase, so every plugin that resolves a folder in onLoaded got a
121
+ // relative one. `path.join('./', 'schemas')` is `schemas`, which reaches
122
+ // import() as a BARE SPECIFIER — so schemas, presets and layout sidecars
123
+ // all failed with "Cannot find package 'schemas'" on a clean build that
124
+ // was otherwise green.
125
+ //
126
+ // Stage two exists to pick up what stage one could not know about. It has
127
+ // no business restating what stage one already produced and the engine has
128
+ // since corrected.
129
+ const parsed = commander.opts()
130
+ for (const option of commander.options) {
131
+ if (!declared.has(option.flags)) continue
132
+ const name = option.attributeName()
133
+ if (parsed[name] !== undefined) runtime.options[name] = parsed[name]
134
+ }
135
+ }
136
+
137
+ // The plugin-declared options carried by a client's argv.
138
+ //
139
+ // A forwarded build is answered by an INSTANCE, which parsed its own argv
140
+ // (`--watch`) and never saw the client's. Core's own request options — json,
141
+ // clear, renderPresets — each travel explicitly for exactly this reason. A
142
+ // plugin option has to as well, or `mikser --lighthouse` works with nothing
143
+ // listening and silently does nothing with a watcher up, which is the failure
144
+ // this whole surface exists to remove.
145
+ //
146
+ // Read from the argv the client sent, against the table the INSTANCE has:
147
+ // both processes load the same config, so the instance knows every option the
148
+ // client could legitimately have passed.
149
+ export function pluginOptionsFrom(argv) {
150
+ const commander = runtime.engine?.commander
151
+ if (!commander || !declared.size || !Array.isArray(argv)) return {}
152
+ // A throwaway parse: `parseOptions` reads without applying, so the
153
+ // instance's own options are untouched by asking what a client sent.
154
+ let parsed
155
+ try {
156
+ const probe = commander.createCommand()
157
+ for (const flags of declared.keys()) probe.option(flags, '')
158
+ probe.allowUnknownOption(true).allowExcessArguments(true)
159
+ probe.parse(argv, { from: 'user' })
160
+ parsed = probe.opts()
161
+ } catch {
162
+ return {}
163
+ }
164
+ // Only what a plugin declared. Core's options travel on the request
165
+ // itself, and re-applying them from argv here would give two sources for
166
+ // one answer.
167
+ const names = new Set(Object.keys(parsed))
168
+ const values = {}
169
+ for (const name of names) {
170
+ if (parsed[name] !== undefined) values[name] = parsed[name]
171
+ }
172
+ return values
173
+ }
package/src/engine.js CHANGED
@@ -13,6 +13,7 @@ import { OPERATION, TASKS } from './constants.js'
13
13
  import { changeExtension, formatErrorContext, projectMeta, lookupKeys, siteRootFor } from './utils.js'
14
14
  import { reportRendered, reportSkipped, reportError, renderErrorCount, emitReport, finishCycle, reportAssetUse, assetUse } from './report.js'
15
15
  import { checkReferences } from './references.js'
16
+ import { completeCliParse } from './cli.js'
16
17
  import { fingerprintOutputs } from './fingerprint.js'
17
18
  import { toolSchemas, invokeTool, toolResultText, toolResultFailed } from './tools.js'
18
19
  import { registerBuiltinTools } from './builtin-tools.js'
@@ -31,7 +32,7 @@ import { queryContext } from './database/query-context.js'
31
32
  // Build a structuredClone-safe copy of runtime.options for WORKER
32
33
  // dispatch. Plugin surfaces live under `runtime.options.<plugin>` per
33
34
  // the engine's namespacing convention and routinely hold functions
34
- // (e.g. `runtime.options.layouts.inspect`, `runtime.options.preview.get`)
35
+ // (e.g. the `layouts` service's inspect(), the `preview` service's get())
35
36
  // — those don't cross thread boundaries via Piscina's structured clone.
36
37
  //
37
38
  // Per-key probe: anything that survives `structuredClone(value)` passes
@@ -538,7 +539,6 @@ export async function setup(options) {
538
539
  .option('-c --config <file>', 'set mikser mikser.config.js location', './mikser.config.js')
539
540
  .option('-m --mode <mode>', 'set mikser runtime mode', 'development')
540
541
  .option('-r --clear', 'clear current state before execution', false)
541
- .option('--render-presets [name]', 're-render preset derivatives whose sources and revisions are unchanged; with a name, only that preset')
542
542
  .option('-o --output-folder <folder>', 'set mikser output folder relative to working folder', 'out')
543
543
  .option('-w --watch', 'watch entities for changes', false)
544
544
  .option('-f --force', 'rebuild everything; disable incremental dispatch', false)
@@ -599,7 +599,10 @@ Which check answers which question:
599
599
  Are the derivatives current?
600
600
  --render-presets [n] re-derives every preset, or one by name, without
601
601
  touching anything else. For a preset edited without
602
- bumping its revision.
602
+ bumping its revision. Declared by the assets
603
+ plugin, so it exists only in a project that loads
604
+ one — and a config without assets() refuses it as
605
+ unknown rather than accepting it and doing nothing.
603
606
 
604
607
  Start over.
605
608
  --clear removes the output folder and reopens the cache.
@@ -632,7 +635,34 @@ Which check answers which question:
632
635
 
633
636
  The full version, with what each code means: docs/diagnostics.md`)
634
637
 
635
- Object.assign(runtime.options, options || runtime.engine.commander.parse(process.argv).opts())
638
+ // Stage one of two. The config names the plugins and is not read
639
+ // until onLoad, so at this moment the engine does not yet know every
640
+ // option this build understands — a plugin's is not registered. Left
641
+ // strict, `mikser --lighthouse` would be rejected before the plugin
642
+ // that defines it had been constructed.
643
+ //
644
+ // Tolerated here and decided in stage two, where the table is complete
645
+ // — see completeCliParse(). The 9.81.0 refusal is not weakened, it is
646
+ // moved to the point where "unknown" can be answered.
647
+ runtime.engine.commander.allowUnknownOption(true).allowExcessArguments(true)
648
+
649
+ // --help is answered in stage two, for the same reason the parse is.
650
+ //
651
+ // Commander prints help and exits the moment it sees the flag, which
652
+ // in stage one is before any plugin has been constructed — so the help
653
+ // would list core's options and silently omit every option the
654
+ // project's own plugins add. A help text that is missing the flag you
655
+ // are looking for is worse than a slower one.
656
+ //
657
+ // Held out of this parse and answered after the table is complete. It
658
+ // does mean `--help` now loads the config, which is the honest cost of
659
+ // describing THIS project's mikser rather than a generic one.
660
+ const helpFlags = ['--help', '-h']
661
+ runtime.engine.helpRequested = process.argv.some(arg => helpFlags.includes(arg))
662
+ const argv = runtime.engine.helpRequested
663
+ ? process.argv.filter(arg => !helpFlags.includes(arg))
664
+ : process.argv
665
+ Object.assign(runtime.options, options || runtime.engine.commander.parse(argv).opts())
636
666
  // runtime.options.info gates the progress bar — gauge stays
637
667
  // silent in --debug/--trace modes because logs are voluminous
638
668
  // there and a bar on top would just be noise.
@@ -778,6 +808,15 @@ The full version, with what each code means: docs/diagnostics.md`)
778
808
  })
779
809
 
780
810
  onLoaded(async () => {
811
+ // Stage two of the parse, before anything reads an option.
812
+ //
813
+ // Every plugin has been constructed by now and has had its chance to
814
+ // declare, so the table is complete and argv is parsed against all of
815
+ // it. This engine hook is registered when the module is imported, so
816
+ // it runs ahead of every plugin's own onLoaded — which is what makes
817
+ // it safe for a plugin to read its own option there.
818
+ completeCliParse()
819
+
781
820
  const logger = useLogger()
782
821
  logger.debug(runtime.options, 'Mikser options')
783
822
 
@@ -803,8 +842,18 @@ The full version, with what each code means: docs/diagnostics.md`)
803
842
  // The same three commands the instance answers over the socket —
804
843
  // one implementation, so a forwarded --audit-output cannot disagree with a
805
844
  // local one about what it checked.
806
- const code = await runReportOnly()
807
- if (code !== null) process.exit(code)
845
+ //
846
+ // --tools and --tool are NOT among them: they are dispatched at `import`
847
+ // instead, because the tool registry is not complete until every
848
+ // plugin's onLoaded has run and this hook runs ahead of all of them.
849
+ // The import dispatch existed already and was documented as
850
+ // load-bearing, but this call reached the same code first and exited,
851
+ // so it never ran — and every tool a plugin registers was missing from
852
+ // the CLI while the listing looked healthy, just short.
853
+ if (!runtime.options.tools && !runtime.options.tool) {
854
+ const code = await runReportOnly()
855
+ if (code !== null) process.exit(code)
856
+ }
808
857
  })
809
858
 
810
859
  onRender(async (signal) => {
@@ -1012,7 +1061,7 @@ The full version, with what each code means: docs/diagnostics.md`)
1012
1061
  mc.port2.unref()
1013
1062
  renderOptions.port = mc.port1
1014
1063
  // Strip plugin-surface functions
1015
- // (runtime.options.layouts.inspect, etc.) so
1064
+ // (the layouts service's inspect, etc.) so
1016
1065
  // Piscina's structured clone doesn't choke on
1017
1066
  // them. Engine-side primitives pass through;
1018
1067
  // plugin surfaces are reachable via the
@@ -1459,30 +1508,17 @@ The full version, with what each code means: docs/diagnostics.md`)
1459
1508
  if (failed) logger.error('Mikser completed with %d render error%s', failed, failed === 1 ? '' : 's')
1460
1509
  else logger.notice('Mikser completed')
1461
1510
 
1462
- // Is everything the templates linked to actually there?
1463
- //
1464
- // The URL helpers build paths; they do not resolve them. So a preset
1465
- // that never ran, a library that was not copied, or a template naming
1466
- // an extension the preset stopped producing all yield a well-formed
1467
- // link to a file that does not exist — and the only symptom is a
1468
- // missing image on the deployed site, found by a person.
1511
+ // The `render-presets-unhandled` guard is gone, and so is the state it
1512
+ // read.
1469
1513
  //
1470
- // Checked at the end of the cycle because that is the first moment the
1471
- // answer is stable: derivatives are produced during the cycle, so
1472
- // asking any earlier would report files that were about to appear.
1473
- // --render-presets with nothing to consume it.
1474
- //
1475
- // The flag is implemented by the assets plugin, so without that plugin
1476
- // it reaches nobody: the build runs normally, nothing is re-derived,
1477
- // and the operator is left to notice. Checked here rather than at
1478
- // onLoaded because the engine's own onLoaded is registered first and
1479
- // runs before any plugin has set itself up.
1480
- if (runtime.options.renderPresets && !runtime.state?.assets?.renderPresetsHandled) {
1481
- useLogger().error({ code: 'render-presets-unhandled' },
1482
- '--render-presets was passed, but no assets plugin is loaded to act on it. '
1483
- + 'Nothing was re-derived. Add assets() to the plugins array, or drop the flag.')
1484
- process.exitCode = 1
1485
- }
1514
+ // It existed because core declared `--render-presets` while the assets
1515
+ // plugin implemented it, so the flag could be passed at a build that
1516
+ // had no plugin to act on it accepted, ignored, and reported after
1517
+ // the fact. The plugin declares the option itself now, so a config
1518
+ // without assets() does not have the flag at all and a misspelling is
1519
+ // refused by name before anything is built. There is nothing left to
1520
+ // warn about after the event.
1521
+
1486
1522
 
1487
1523
  const brokenTargets = await reportBrokenReferences(useLogger())
1488
1524
  await reportMissingAssets(useLogger(), brokenTargets)
package/src/instance.js CHANGED
@@ -38,6 +38,7 @@ import { onLoaded } from './lifecycle.js'
38
38
  import { renderErrorCount } from './report.js'
39
39
  import { runReportOnly } from './engine.js'
40
40
  import { emitReport } from './report.js'
41
+ import { pluginOptionsFrom } from './cli.js'
41
42
 
42
43
  // Where the endpoint lives.
43
44
  //
@@ -72,6 +73,11 @@ export function socketPath(workingFolder) {
72
73
  //
73
74
  // → { type: 'build', config, clear, renderPresets }
74
75
  // → { type: 'report', config, tool, tools, toolArgs, explain, auditOutput, fingerprint, json }
76
+ //
77
+ // A PLUGIN's options are not listed here and are not hand-carried: they travel
78
+ // on `argv` and are applied by withRequestOutput against the instance's own
79
+ // option table. --render-presets used to be named explicitly, which is what a
80
+ // core-declared plugin flag costs — one more thing to remember in two files.
75
81
  // ← { type: 'log', chunk } (zero or more, in order)
76
82
  // ← { type: 'done', code }
77
83
  // ← { type: 'refused', reason, detail }
@@ -416,6 +422,17 @@ async function withRequestOutput(request, run) {
416
422
  prior[key] = runtime.options[key]
417
423
  if (request[key]) runtime.options[key] = request[key]
418
424
  }
425
+
426
+ // Whatever the client's argv said about a PLUGIN's options.
427
+ //
428
+ // The instance parsed its own argv and never saw the client's, so a flag a
429
+ // plugin declared worked with nothing listening and did nothing with a
430
+ // watcher up. Applied and restored with the rest of the request's
431
+ // contract, so a watcher's own cycle before or after is unaffected.
432
+ for (const [name, value] of Object.entries(pluginOptionsFrom(request.argv))) {
433
+ if (!(name in prior)) prior[name] = runtime.options[name]
434
+ runtime.options[name] = value
435
+ }
419
436
  try {
420
437
  return await run()
421
438
  } finally {
@@ -448,8 +465,10 @@ async function serveBuild(socket, request, logger) {
448
465
  // silent no-op, which is the failure this surface exists to remove —
449
466
  // and restoring it after keeps the watcher from forcing every later
450
467
  // rebuild.
451
- const priorRenderPresets = runtime.options.renderPresets
452
- if (request.renderPresets !== undefined) runtime.options.renderPresets = request.renderPresets
468
+ // Applied for THIS cycle only, like renderPresets. An instance left in
469
+ // force mode would re-render the whole site on every later save.
470
+ const priorForce = runtime.options.force
471
+ if (request.force) runtime.options.force = true
453
472
  try {
454
473
  // The report is emitted by the cycle itself, from inside
455
474
  // rebuild() — the same call a one-shot makes. Nothing here
@@ -470,7 +489,7 @@ async function serveBuild(socket, request, logger) {
470
489
  emitReport()
471
490
  })
472
491
  } finally {
473
- runtime.options.renderPresets = priorRenderPresets
492
+ runtime.options.force = priorForce
474
493
  }
475
494
 
476
495
  // From the render-error count, NOT from process.exitCode.
@@ -1,5 +1,6 @@
1
1
  import { reportEvaluated } from '../report.js'
2
2
  import { countEntities } from '../catalog.js'
3
+ import { cliOption } from '../cli.js'
3
4
  import path from 'node:path'
4
5
  import { mkdir, writeFile, unlink, rm, readFile, symlink, } from 'fs/promises'
5
6
  import { existsSync } from 'node:fs'
@@ -86,6 +87,33 @@ export function assets(options = {}) {
86
87
  changeExtension,
87
88
  constants: { ACTION, OPERATION },
88
89
  }) => {
90
+ // Declared HERE, not in core.
91
+ //
92
+ // Everything this flag does happens in this plugin, and core carried it
93
+ // only because a plugin could not declare an option until 9.100.0. The
94
+ // cost of that was a flag a build could accept with nothing loaded to act
95
+ // on it: passed, ignored, and reported afterwards by a `render-presets-
96
+ // unhandled` guard in the engine. Declared here, a config without assets()
97
+ // simply does not have the option, so the mistake is refused by name
98
+ // before anything is built rather than explained after.
99
+ // The folders this plugin owns, on the command line.
100
+ //
101
+ // They were config-only, because a plugin could not declare an option
102
+ // before 9.100.0 — so overriding one for a single run meant editing the
103
+ // config, which is the file whose checksum decides whether the whole
104
+ // catalog is still valid. A flag is the difference between "try this
105
+ // once" and "invalidate everything".
106
+ //
107
+ // CLI beats config beats default, which is the order every other option
108
+ // here already follows.
109
+ cliOption('--assets <folder>',
110
+ 'folder for derived assets, relative to the working folder (default: assets)')
111
+ cliOption('--presets <folder>',
112
+ 'folder holding the preset modules, relative to the working folder (default: presets)')
113
+ cliOption('--render-presets [name]',
114
+ 're-render preset derivatives whose sources and revisions are unchanged; '
115
+ + 'with a name, only that preset')
116
+
89
117
  const collection = 'presets'
90
118
  const type = 'preset'
91
119
  const checksumMap = new Set()
@@ -425,12 +453,16 @@ export function assets(options = {}) {
425
453
  runtime.engine ??= {}
426
454
  runtime.engine.assets = { explainMissing }
427
455
 
428
- runtime.options.presets = options.presetsFolder || collection
456
+ // `??`, not `||`: an option commander did not see is undefined, and
457
+ // falling through to the config is the point. `||` would also fall
458
+ // through for an intentional empty string, which is a different
459
+ // answer than "not given".
460
+ runtime.options.presets = runtime.options.presets ?? options.presetsFolder ?? collection
429
461
  runtime.options.presetsFolder = path.join(runtime.options.workingFolder, runtime.options.presets)
430
462
  logger.debug('Presets folder: %s', runtime.options.presetsFolder)
431
463
  await mkdir(runtime.options.presetsFolder, { recursive: true })
432
464
 
433
- runtime.options.assets = options.assetsFolder || 'assets'
465
+ runtime.options.assets = runtime.options.assets ?? options.assetsFolder ?? 'assets'
434
466
  runtime.options.assetsFolder = path.join(runtime.options.workingFolder, runtime.options.assets)
435
467
  logger.debug('Assets folder: %s', runtime.options.assetsFolder)
436
468
 
@@ -641,7 +673,6 @@ export function assets(options = {}) {
641
673
  // end of the cycle: a flag that reaches no plugin has to say so,
642
674
  // rather than building normally and leaving the operator to
643
675
  // notice nothing was re-derived.
644
- runtime.state.assets.renderPresetsHandled = true
645
676
 
646
677
  const known = Object.keys(runtime.state.assets.presets)
647
678
  const names = wanted === true ? known : [wanted]
@@ -1,4 +1,5 @@
1
1
  import path from 'path'
2
+ import { cliOption } from '../cli.js'
2
3
  import { mkdir, writeFile, unlink, open } from 'fs/promises'
3
4
  import _ from 'lodash'
4
5
  import sift from 'sift'
@@ -17,9 +18,23 @@ export function data(options = {}) {
17
18
  onBeforeRender,
18
19
  constants: { OPERATION },
19
20
  }) => {
21
+ // The folder this plugin owns, on the command line.
22
+ //
23
+ // Config-only until 9.100.0, because a plugin could not declare an option
24
+ // — so overriding it for one run meant editing the config, which is the
25
+ // file whose checksum decides whether the catalog is still valid. A flag
26
+ // is the difference between trying something once and invalidating
27
+ // everything.
28
+ //
29
+ // CLI beats config beats default. `??` rather than `||` below: an option
30
+ // commander did not see is undefined, and an intentional empty string is a
31
+ // different answer than "not given".
32
+ cliOption('--data <folder>',
33
+ 'folder for emitted data files, relative to the OUTPUT folder (default: data)')
34
+
20
35
  onLoaded(async () => {
21
36
  const logger = useLogger()
22
- runtime.options.data = options.dataFolder || 'data'
37
+ runtime.options.data = runtime.options.data ?? options.dataFolder ?? 'data'
23
38
  runtime.options.dataFolder = path.join(runtime.options.outputFolder, runtime.options.data)
24
39
 
25
40
  logger.debug('Data folder: %s', runtime.options.dataFolder)
@@ -1,4 +1,5 @@
1
1
  import { useSource } from '../source.js'
2
+ import { cliOption } from '../cli.js'
2
3
 
3
4
  // documents — the canonical content source plugin.
4
5
  //
@@ -13,6 +14,13 @@ export function documents(options = {}) {
13
14
  const collection = 'documents'
14
15
  const type = 'document'
15
16
 
17
+ // The folder this plugin owns, on the command line. Config-only until
18
+ // 9.100.0, because a plugin could not declare an option — and editing
19
+ // the config to try another folder once invalidates the catalog,
20
+ // whose checksum covers it. CLI beats config beats default.
21
+ cliOption('--documents <folder>',
22
+ 'folder of source documents, relative to the working folder (default: documents)')
23
+
16
24
  useSource(core, {
17
25
  collection,
18
26
  type,
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path'
2
+ import { cliOption } from '../cli.js'
2
3
  import { mkdir, symlink, unlink, lstat, realpath } from 'fs/promises'
3
4
  import { globby } from 'globby'
4
5
  import pMap from 'p-map'
@@ -23,6 +24,20 @@ export function files(options = {}) {
23
24
  constants: { ACTION },
24
25
  }) => {
25
26
  const collection = 'files'
27
+ // The folder this plugin owns, on the command line.
28
+ //
29
+ // Config-only until 9.100.0, because a plugin could not declare an option
30
+ // — so overriding it for one run meant editing the config, which is the
31
+ // file whose checksum decides whether the catalog is still valid. A flag
32
+ // is the difference between trying something once and invalidating
33
+ // everything.
34
+ //
35
+ // CLI beats config beats default. `??` rather than `||` below: an option
36
+ // commander did not see is undefined, and an intentional empty string is a
37
+ // different answer than "not given".
38
+ cliOption('--files <folder>',
39
+ 'folder of static files copied into the output by symlink, relative to the working folder (default: files)')
40
+
26
41
  const type = 'file'
27
42
 
28
43
  async function ensureLink(relativePath) {
@@ -156,7 +171,7 @@ export function files(options = {}) {
156
171
 
157
172
  onLoaded(async () => {
158
173
  const logger = useLogger()
159
- runtime.options.files = options.filesFolder || collection
174
+ runtime.options.files = runtime.options.files ?? options.filesFolder ?? collection
160
175
  runtime.options.filesFolder = path.join(runtime.options.workingFolder, runtime.options.files)
161
176
 
162
177
  logger.debug('Files folder: %s', runtime.options.filesFolder)
@@ -6,7 +6,7 @@
6
6
  // 1. An in-memory cache (Map<filename, { bytes, mime, expiresAt,
7
7
  // size, deps }>) with LRU eviction past a configurable byte cap.
8
8
  // 2. An Express GET /preview/:filename route that serves cache entries.
9
- // 3. Exposes the cache surface at runtime.options.preview = { store,
9
+ // 3. Offers the cache surface as the `preview` service = { store,
10
10
  // get, stats, config } so other plugins (mikser-io-mcp's
11
11
  // mikser_preview_render tool, library-mode callers) can stash bytes
12
12
  // and retrieve them by URL without going through MCP.
@@ -29,6 +29,7 @@
29
29
  // isn't mounted.
30
30
 
31
31
  import sift from 'sift'
32
+ import { provideService } from '../services.js'
32
33
 
33
34
  export function preview(options = {}) {
34
35
  return ({ runtime, onLoaded, onPersist, useJournal, useLogger, registerRoute, constants: { OPERATION } }) => {
@@ -98,13 +99,14 @@ export function preview(options = {}) {
98
99
  }
99
100
  }
100
101
 
101
- // Expose the cache surface at runtime.options.preview so other
102
- // plugins / library callers can use it without going through MCP.
103
- // `config` is part of the surface too — mikser-io-mcp's
104
- // mikser_preview_render reads it to derive URL paths + TTL clamps.
105
- // Done at factory-eval time (before any onLoaded fires) so a
106
- // later plugin's onLoad / onLoaded can already see it.
107
- runtime.options.preview = { store, get, stats, config }
102
+ // Offer the cache surface as a service, so a plugin that wants it asks
103
+ // core rather than reaching into runtime.options.preview. `config` is
104
+ // part of the surface too — mikser-io-mcp's mikser_preview_render reads
105
+ // it to derive URL paths + TTL clamps.
106
+ //
107
+ // Provided at factory-eval time, before any hook runs, so a consumer's
108
+ // onLoad / onLoaded can already see it whatever the plugin order.
109
+ provideService('preview', { store, get, stats, config }, { plugin: 'mikser-io' })
108
110
 
109
111
  // Per-cycle invalidation. For every preview entry that has `deps`,
110
112
  // walk this cycle's catalog mutations and evict the entry if any
@@ -144,7 +146,7 @@ export function preview(options = {}) {
144
146
 
145
147
  // HTTP route: served regardless of whether MCP is on, so previews
146
148
  // are also reachable from library-mode callers that stored bytes
147
- // via runtime.options.preview.store() directly.
149
+ // via the preview service's store() directly.
148
150
  onLoaded(async () => {
149
151
  const logger = useLogger()
150
152
  const app = runtime.options.app
@@ -1,4 +1,5 @@
1
1
  import { mkdir, symlink, rename, unlink } from 'node:fs/promises'
2
+ import { cliOption } from '../cli.js'
2
3
  import { createWriteStream } from 'node:fs'
3
4
  import lodash from 'lodash'
4
5
  import deepdash from 'deepdash'
@@ -27,6 +28,20 @@ export function resources(options = {}) {
27
28
  updateProgress,
28
29
  constants: { OPERATION },
29
30
  }) => {
31
+ // The folder this plugin owns, on the command line.
32
+ //
33
+ // Config-only until 9.100.0, because a plugin could not declare an option
34
+ // — so overriding it for one run meant editing the config, which is the
35
+ // file whose checksum decides whether the catalog is still valid. A flag
36
+ // is the difference between trying something once and invalidating
37
+ // everything.
38
+ //
39
+ // CLI beats config beats default. `??` rather than `||` below: an option
40
+ // commander did not see is undefined, and an intentional empty string is a
41
+ // different answer than "not given".
42
+ cliOption('--resources <folder>',
43
+ 'folder of library resources linked into the output, relative to the working folder (default: resources)')
44
+
30
45
  const collection = 'resources'
31
46
  const type = 'resource'
32
47
 
@@ -46,7 +61,7 @@ export function resources(options = {}) {
46
61
  : resourcesName,
47
62
  }
48
63
 
49
- runtime.options.resources = options.resourcesFolder || collection
64
+ runtime.options.resources = runtime.options.resources ?? options.resourcesFolder ?? collection
50
65
  runtime.options.resourcesFolder = path.join(runtime.options.workingFolder, runtime.options.resources)
51
66
  logger.debug('Resources folder: %s', runtime.options.resourcesFolder)
52
67
 
package/src/plugins.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { useLogger } from './engine.js'
2
2
  import { onLoad } from './lifecycle.js'
3
+ import { resetServices } from './services.js'
3
4
  import runtime from './runtime.js'
4
5
 
5
6
  import * as core from '../index.js'
@@ -23,6 +24,13 @@ import * as core from '../index.js'
23
24
  onLoad(() => {
24
25
  const logger = useLogger()
25
26
 
27
+ // The service and extension-point registries are module state, which
28
+ // outlives one load phase — a watcher re-reads its config in the same
29
+ // process. Clearing here, before any factory runs, means a reload
30
+ // rebuilds them from scratch instead of a provider colliding with the
31
+ // copy of itself it registered last time.
32
+ resetServices()
33
+
26
34
  runtime.options.plugins = (runtime.options.plugins ?? [])
27
35
  .concat(runtime.config.plugins ?? [])
28
36
  .filter(Boolean)
@@ -110,11 +118,35 @@ onLoad(() => {
110
118
  if (registeredPostprocessors) parts.push(`${registeredPostprocessors} postprocessor${registeredPostprocessors === 1 ? '' : 's'}`)
111
119
  logger.info('Loading plugins: %s', parts.join(', '))
112
120
 
113
- for (const factoryReturn of factoryEntries) {
121
+ // Which plugin registered which hook, so a phase can be broken down.
122
+ //
123
+ // `timings` said `finalized: 19400ms` and stopped there — and finalized is
124
+ // where the reference check, schemas, lint and lighthouse all live, so the
125
+ // one number said nothing about which of them cost it. Downstream that
126
+ // took three separate measurements to attribute a 465ms lint pass.
127
+ //
128
+ // Labelled retroactively, because a plugin's identity is not knowable
129
+ // until its factory RETURNS: the entry in the plugins array is already the
130
+ // factory's result, so there is no name to read going in, and the
131
+ // descriptor with `collection` on it only exists coming out. So the hooks
132
+ // are diffed across the call and tagged with what the call produced.
133
+ const hookNames = Object.keys(runtime.hooks)
134
+ for (const [index, factoryReturn] of factoryEntries.entries()) {
135
+ const before = new Map(hookNames.map(name => [name, runtime.hooks[name].length]))
136
+ let descriptor
114
137
  try {
115
- factoryReturn(core)
138
+ descriptor = factoryReturn(core)
116
139
  } catch (err) {
117
140
  logger.error('Plugin factory threw on registration: %s', err.message)
141
+ continue
142
+ }
143
+ const label = descriptor?.collection ?? descriptor?.type ?? `plugin-${index + 1}`
144
+ for (const name of hookNames) {
145
+ for (const hook of runtime.hooks[name].slice(before.get(name))) {
146
+ // A plugin registering the same function twice keeps its first
147
+ // label rather than being renamed by a later registration.
148
+ if (typeof hook === 'function' && !hook.mikserPlugin) hook.mikserPlugin = label
149
+ }
118
150
  }
119
151
  }
120
152
  })
package/src/report.js CHANGED
@@ -126,6 +126,8 @@ export function resetReport() {
126
126
  // asking. The boot phases therefore appear in the first cycle's report and
127
127
  // not in later ones, which is what actually happened.
128
128
  runtime.state.timings = {}
129
+ runtime.state.pluginTimings = {}
130
+ runtime.state.activity = { rendered: 0, changed: 0 }
129
131
  }
130
132
 
131
133
  // Published on the runtime so runtime.js can start a fresh cycle for a
@@ -194,6 +196,7 @@ export function reportWipe(cause, detail = {}) {
194
196
  // One source whose bytes moved. The complement of reportGated: between them
195
197
  // every file the engine looked at is accounted for.
196
198
  export function reportChanged(id) {
199
+ if (id) activity().changed++
197
200
  if (!reportWanted() || !id) return
198
201
  const store = changedStore()
199
202
  store.count++
@@ -277,6 +280,7 @@ export function reportGated(count = 1) {
277
280
  // `matched`, `dependency` each mean something specific, and a single
278
281
  // polymorphic key would push the type switch onto every consumer.
279
282
  export function reportRendered(entity, reason, decision = {}) {
283
+ activity().rendered++
280
284
  if (!reportWanted()) return
281
285
  store().rendered.push({
282
286
  id: entity?.id,
@@ -425,6 +429,45 @@ export function renderErrorCount() {
425
429
  return errorStore().length
426
430
  }
427
431
 
432
+ // A bare count of what the cycle did, kept whether or not anyone is reading
433
+ // the report.
434
+ //
435
+ // The rendered/skipped/unchanged ARRAYS are only recorded when something can
436
+ // read them — one entry per entity per cycle is not free, which is why `gated`
437
+ // was already a bare count. So a check asking "did this cycle do anything"
438
+ // cannot read those arrays: without --json they are empty, and a cold build
439
+ // that rendered the whole site looks identical to a no-op. That is exactly the
440
+ // mistake the first version of cycleMovedNothing() made, and it reported
441
+ // "nothing moved" on a build that had just rendered.
442
+ //
443
+ // Two integers cost nothing and are always true.
444
+ function activity() {
445
+ runtime.state ??= {}
446
+ runtime.state.activity ??= { rendered: 0, changed: 0 }
447
+ return runtime.state.activity
448
+ }
449
+
450
+ // Did this cycle move anything?
451
+ //
452
+ // For a check that reads the OUTPUT — a linter, an audit — a cycle that
453
+ // rendered nothing and changed nothing produced the same bytes the last one
454
+ // did, so running again spends time to reprint what was already said.
455
+ // Downstream that was measured: a lint pass costing 465ms of every no-op
456
+ // rebuild, and a Lighthouse audit costing 19 SECONDS of one.
457
+ //
458
+ // Both halves matter. `rendered` alone is not enough: files() emits by
459
+ // symlinking rather than rendering, so a new static file changes the output
460
+ // with nothing rendered. `changed` covers that — it counts the sources whose
461
+ // bytes moved this cycle.
462
+ //
463
+ // A plugin skipping on this must SAY so. "Nothing to report" and "did not run"
464
+ // are indistinguishable otherwise, which is the property this codebase keeps
465
+ // having to remove.
466
+ export function cycleMovedNothing() {
467
+ const { rendered, changed } = activity()
468
+ return rendered === 0 && changed === 0
469
+ }
470
+
428
471
  // The cause, and enough detail to act on it.
429
472
  //
430
473
  // A wipe outranks changed sources: when the cache went, everything is a
@@ -451,6 +494,12 @@ function phaseTimings() {
451
494
  const entries = Object.entries(timings)
452
495
  .map(([phase, { ms, calls }]) => ({ phase, ms: Math.round(ms * 10) / 10, calls }))
453
496
  .sort((a, b) => b.ms - a.ms)
497
+ // Inside the phases, not beside them: a plugin's time is part of its
498
+ // phase's, so this is a breakdown rather than an addition.
499
+ const byPlugin = Object.values(runtime.state?.pluginTimings ?? {})
500
+ .map(({ phase, plugin, ms, calls }) => ({ phase, plugin, ms: Math.round(ms * 10) / 10, calls }))
501
+ .sort((a, b) => b.ms - a.ms)
502
+
454
503
  return {
455
504
  // This cycle, summed. The phases below add up to it.
456
505
  total: Math.round(entries.reduce((sum, e) => sum + e.ms, 0) * 10) / 10,
@@ -467,6 +516,7 @@ function phaseTimings() {
467
516
  // misled by a name like "elapsed".
468
517
  processUptime: Math.round(process.uptime() * 1000 * 10) / 10,
469
518
  phases: entries,
519
+ ...(byPlugin.length ? { plugins: byPlugin } : {}),
470
520
  }
471
521
  }
472
522
 
package/src/runtime.js CHANGED
@@ -82,6 +82,19 @@ const runtime = {
82
82
  //
83
83
  // Accumulated per phase rather than assigned, because a phase runs more
84
84
  // than once in a watch process and a cycle can re-enter one.
85
+ // Per plugin within a phase. Kept apart from `phases` so the phase totals
86
+ // still add up to `total` — a plugin's time is INSIDE its phase, not
87
+ // beside it, and adding both would double-count the run.
88
+ recordPluginPhase(phaseName, plugin, ms) {
89
+ if (!phaseName || !plugin) return
90
+ this.state ??= {}
91
+ const byPlugin = (this.state.pluginTimings ??= {})
92
+ const key = `${phaseName}:${plugin}`
93
+ const entry = (byPlugin[key] ??= { phase: phaseName, plugin, ms: 0, calls: 0 })
94
+ entry.ms += ms
95
+ entry.calls++
96
+ },
97
+
85
98
  recordPhase(phaseName, ms) {
86
99
  if (!phaseName) return
87
100
  this.state ??= {}
@@ -101,7 +114,14 @@ const runtime = {
101
114
  try {
102
115
  for (let hook of hooks) {
103
116
  if (signal?.aborted) throw new AbortError()
117
+ // Timed per hook when it belongs to a plugin, so a phase can
118
+ // be broken down by who spent it. `finalized` alone is where
119
+ // the reference check, schemas, lint and an audit all live.
120
+ const hookStarted = hook.mikserPlugin ? performance.now() : 0
104
121
  await hook(signal)
122
+ if (hook.mikserPlugin) {
123
+ this.recordPluginPhase(phaseName, hook.mikserPlugin, performance.now() - hookStarted)
124
+ }
105
125
  }
106
126
  } finally {
107
127
  // In `finally`, so a phase that threw still reports what it spent
@@ -0,0 +1,88 @@
1
+ // What one plugin offers another, without either importing the other.
2
+ //
3
+ // Plugins reached into each other through `runtime.options`:
4
+ //
5
+ // runtime.options.layouts.inspect read by core
6
+ // runtime.options.schemas.lookup read by forms, layouts, ocr
7
+ // runtime.options.preview.get read by core, layouts, mcp
8
+ // runtime.options.roles read by mcp
9
+ //
10
+ // It worked, and it was wrong in a specific way: mikser-io-mcp and
11
+ // mikser-io-layouts were coupled through a shared mutable object neither of
12
+ // them owns. Nothing declared the relationship, so nothing could check it —
13
+ // a consumer's `if (!runtime.options.layouts) return` reads as a null check
14
+ // but is really an is-that-plugin-installed check wearing a disguise.
15
+ //
16
+ // It also cost the two best names. Commander derives an option's property
17
+ // from its long flag, so `--layouts <folder>` writes runtime.options.layouts
18
+ // — on top of the inspect API, after the load phase, with the failure
19
+ // surfacing three plugins away.
20
+ //
21
+ // This covers ONE direction: one plugin provides, others consume. The other
22
+ // direction — many plugins adding to a surface one plugin serves — already
23
+ // has a home for each surface it applies to: tools.js, routes.js, roles.js
24
+ // and cli.js. A generic contribution registry would be a fifth way to do
25
+ // what those four already do.
26
+ //
27
+ // So: core holds the registry, and neither side names the other.
28
+
29
+ const services = new Map() // name -> { api, plugin }
30
+
31
+ // Publish an API under a name other plugins can ask for.
32
+ //
33
+ // provideService('layouts', { inspect }, { plugin: 'mikser-io-layouts' })
34
+ //
35
+ // Call at factory-eval time, before any hook runs, so that a consumer's
36
+ // onLoaded can already see it. Same rule as cliOption: declare during
37
+ // construction, read during a hook.
38
+ export function provideService(name, api, { plugin } = {}) {
39
+ if (!name || !api) return
40
+ const existing = services.get(name)
41
+ if (existing) {
42
+ // Two providers for one name is a configuration mistake, not a
43
+ // precedence question. Last-write-wins would hand consumers whichever
44
+ // plugin happened to be constructed later — the exact order
45
+ // dependence this registry exists to remove. Thrown rather than
46
+ // logged: this runs at factory-eval time, where plugins.js already
47
+ // catches, names the plugin and carries on with the rest.
48
+ throw new Error(
49
+ `Service "${name}" is already provided by ${existing.plugin ?? 'another plugin'}` +
50
+ `${plugin ? `; ${plugin} tried to provide it too` : ''}. Only one plugin may provide a service.`)
51
+ }
52
+ services.set(name, { api, plugin })
53
+ }
54
+
55
+ // Ask for a service. Returns undefined when nothing provides it, so a
56
+ // consumer degrades on purpose:
57
+ //
58
+ // const layouts = useService('layouts')
59
+ // if (!layouts) return // layouts plugin isn't installed
60
+ //
61
+ // This replaces `if (!runtime.options.mcp) return`, which read as a null
62
+ // check but was really an is-that-plugin-loaded check wearing a disguise.
63
+ export function useService(name) {
64
+ return services.get(name)?.api
65
+ }
66
+
67
+ // Ask for a service the caller cannot work without. Throws naming the
68
+ // package that provides it, because "cannot read properties of undefined"
69
+ // does not tell anyone which npm install they are missing.
70
+ export function requireService(name, { from } = {}) {
71
+ const found = services.get(name)
72
+ if (found) return found.api
73
+ throw new Error(
74
+ `No plugin provides the "${name}" service` +
75
+ (from ? `. Install ${from} and add it to the plugins array.` : '.'))
76
+ }
77
+
78
+ // Drop everything. The registries are module state, which outlives a single
79
+ // run inside one process — tests, and the watcher's repeated config reloads.
80
+ export function resetServices() {
81
+ services.clear()
82
+ }
83
+
84
+ // What is registered right now. For --explain and the MCP inventory tool:
85
+ // "which plugin provides what" is a question people ask.
86
+ export function serviceInventory() {
87
+ return [...services].map(([name, { plugin }]) => ({ name, plugin }))
88
+ }
package/src/source.js CHANGED
@@ -337,9 +337,19 @@ export function useSource(core, options) {
337
337
  // have already passed. We do folder resolution here — by which
338
338
  // point engine has set workingFolder and journal + catalog
339
339
  // have initialized.
340
- absFolder = path.isAbsolute(folder)
341
- ? folder
342
- : path.join(runtime.options.workingFolder, folder)
340
+ // A `--<collection> <folder>` flag, if the plugin declared one, wins
341
+ // over the configured folder.
342
+ //
343
+ // Resolved HERE rather than where useSource was called, and that is
344
+ // the whole point: plugin factories run during the load phase, and the
345
+ // CLI table is not complete until after it — so an option read at
346
+ // construction is always undefined. documents() read it there first
347
+ // and `--documents content` silently did nothing, which is the shape
348
+ // of bug this option mechanism exists to prevent rather than create.
349
+ const configured = runtime.options[collection] ?? folder
350
+ absFolder = path.isAbsolute(configured)
351
+ ? configured
352
+ : path.join(runtime.options.workingFolder, configured)
343
353
  runtime.options[`${collection}Folder`] = absFolder
344
354
  // The authoritative set of folders whose files become entities.
345
355
  //
package/src/tools.js CHANGED
@@ -7,13 +7,17 @@
7
7
  // the first. Closing that by adding a CLI flag per tool would drift the moment
8
8
  // anyone added a tool.
9
9
  //
10
- // One direction, today. The mcp plugin mirrors its registrations into here, so
11
- // every tool it registers is reachable from the CLI. The reverse is NOT true: a
12
- // tool registered directly against this registry does not appear in an MCP
13
- // session, because the substrate binds sessions from its own list and MCP wants
14
- // a zod shape for `inputSchema` while the engine is deliberately zod-free.
15
- // Registering through `runtime.options.mcp` therefore remains the way to reach
16
- // both surfaces, and that is what every tool does now.
10
+ // Both directions. The mcp plugin mirrors its own registrations into here, and
11
+ // binds every tool registered here into each session prefixing on the way
12
+ // out, since `mikser_` belongs to MCP's flat namespace and not to the engine.
13
+ // So a plugin registers once, against this registry, and reaches both surfaces
14
+ // without depending on the mcp plugin being installed, or on where it sits in
15
+ // the plugins array.
16
+ //
17
+ // `inputSchema` is stored opaquely, which is what makes that possible: a
18
+ // plugin may describe its parameters in the neutral vocabulary below or hand
19
+ // over a zod shape, and the engine passes either through untouched rather than
20
+ // taking a dependency on zod to hold it.
17
21
  //
18
22
  // So the REGISTRY is substrate and the transports are consumers. ADR-0006's
19
23
  // five tests, which MCP itself failed on release cadence:
@@ -50,6 +54,13 @@ export function registerTool(name, definition = {}, handler) {
50
54
  runtime.engine?.logger?.debug('Tool %s re-registered, replacing the previous handler', name)
51
55
  }
52
56
  tools.set(name, {
57
+ // The whole definition, not the three fields core happens to read.
58
+ // A transport needs more than core does — `mutates: true` is how a
59
+ // tool says it changes something, and MCP wraps those differently.
60
+ // Dropping unknown keys here meant a plugin could only reach that
61
+ // behaviour by registering with the mcp plugin directly, which is
62
+ // exactly the coupling this registry exists to remove.
63
+ ...definition,
53
64
  name,
54
65
  description: definition.description ?? '',
55
66
  inputSchema: definition.inputSchema ?? {},
@@ -7,6 +7,7 @@
7
7
 
8
8
  import _ from 'lodash'
9
9
  import realRuntime from '../src/runtime.js'
10
+ import { resetServices } from '../src/services.js'
10
11
  import { matchEntity, normalize, changeExtension, getFormatInfo, checksum, AbortError } from '../src/utils.js'
11
12
 
12
13
  const OPERATION = {
@@ -31,6 +32,11 @@ export function createHarness({
31
32
  entities = [],
32
33
  journal = [],
33
34
  } = {}) {
35
+ // A fresh harness is a fresh world. The service registry is module state,
36
+ // so without this the previous test's provider is still registered and
37
+ // constructing the plugin again is (correctly) an error — every package
38
+ // building a plugin per test would otherwise have to remember to clear it.
39
+ resetServices()
34
40
  const hookNames = [
35
41
  'load', 'loaded', 'import', 'imported',
36
42
  'process', 'processed', 'persist', 'persisted',