@north-light/crouter 0.3.187 → 0.3.188

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.
@@ -85,6 +85,19 @@ test('the scope\u2019s own config.json overrides its plugins; a disabled plugin
85
85
  assert.equal(kinds['applet-builder']?.whenToUse, 'plugin gloss'); // patch merges, not replaces
86
86
  assert.equal(kinds['ghost'], undefined);
87
87
  });
88
+ test('a whenToUse-only entry re-glosses an existing kind without stripping its model tier', () => {
89
+ // The pre-change footgun: `{whenToUse}` parsed as a FULL entry and wholly
90
+ // replaced the kind, silently dropping explore off its cheap model tier.
91
+ // Now every entry field-merges over an existing kind; only a NEW kind
92
+ // requires whenToUse (a whenToUse-less new kind is dropped).
93
+ const root = userRoot();
94
+ writeFileSync(join(root, 'config.json'), JSON.stringify({ kinds: { explore: { whenToUse: 'custom explore gloss' }, 'new-glossless': { model: 'openai/light' } } }));
95
+ const kinds = readMergedLaunchConfig().kinds;
96
+ assert.equal(kinds['explore']?.whenToUse, 'custom explore gloss');
97
+ assert.equal(kinds['explore']?.model, 'openai/light'); // builtin tier survives
98
+ assert.equal(kinds['explore']?.orchestratorModel, 'anthropic/strong');
99
+ assert.equal(kinds['new-glossless'], undefined);
100
+ });
88
101
  test('a project-root plugin layers above user config, and the project config above the plugin', () => {
89
102
  const uRoot = userRoot();
90
103
  writeFileSync(join(uRoot, 'config.json'), JSON.stringify({ kinds: { shared: { whenToUse: 'user gloss', model: 'openai/light' } } }));
@@ -265,42 +265,20 @@ function mergeSpawnEnv(raw, base = []) {
265
265
  }
266
266
  return { allow: [...new Set([...base, ...extra])] };
267
267
  }
268
- /** Validate one raw `kinds.<name>` entry into a `KindConfig`, or drop it
269
- * (return null) rather than throwing — an invalid kind entry in config.json
270
- * must not break config parsing for every other kind. `whenToUse` is the
271
- * only required field. */
272
- function normalizeKindEntry(raw) {
273
- if (raw === null || typeof raw !== 'object')
274
- return null;
275
- const r = raw;
276
- if (typeof r.whenToUse !== 'string' || r.whenToUse.trim() === '')
277
- return null;
278
- const out = { whenToUse: r.whenToUse };
279
- if (typeof r.model === 'string' && r.model.trim() !== '')
280
- out.model = r.model;
281
- if (typeof r.orchestratorModel === 'string' && r.orchestratorModel.trim() !== '')
282
- out.orchestratorModel = r.orchestratorModel;
283
- const isStringArray = (v) => Array.isArray(v) && v.every((item) => typeof item === 'string');
284
- if (isStringArray(r.tools))
285
- out.tools = r.tools;
286
- if (isStringArray(r.extensions))
287
- out.extensions = r.extensions;
288
- if (isStringArray(r.availableTo))
289
- out.availableTo = r.availableTo;
290
- return out;
291
- }
292
- /** Validate one raw `kinds.<name>` entry as a sparse PATCH — an entry with no
293
- * `whenToUse` that overrides individual launch knobs (model/orchestratorModel/
294
- * tools/extensions/availableTo) of a kind some lower-precedence scope already
295
- * defines. This is what lets a scope (e.g. a profile) persist ONLY
296
- * `{ "model": "ultra" }` for a kind instead of freezing a full registry
297
- * snapshot to disk (the same staleness trap `sparseModelLaddersToPersist`
298
- * exists to avoid). Returns null when no valid field is present. */
299
- function normalizeKindPatch(raw) {
268
+ /** Collect the valid `KindConfig` fields of one raw `kinds.<name>` entry —
269
+ * every field is optional here, `whenToUse` included; mistyped fields are
270
+ * dropped, never thrown (an invalid field in config.json must not break
271
+ * config parsing for every other kind — the loud gate is install time, see
272
+ * `invalidPluginKindsReasons`). Returns null when no valid field is present.
273
+ * Whether `whenToUse` is REQUIRED is `mergeKinds`'s call: only an entry that
274
+ * introduces a kind no lower layer defines needs one. */
275
+ function normalizeKindFields(raw) {
300
276
  if (raw === null || typeof raw !== 'object')
301
277
  return null;
302
278
  const r = raw;
303
279
  const out = {};
280
+ if (typeof r.whenToUse === 'string' && r.whenToUse.trim() !== '')
281
+ out.whenToUse = r.whenToUse;
304
282
  if (typeof r.model === 'string' && r.model.trim() !== '')
305
283
  out.model = r.model;
306
284
  if (typeof r.orchestratorModel === 'string' && r.orchestratorModel.trim() !== '')
@@ -315,29 +293,31 @@ function normalizeKindPatch(raw) {
315
293
  return Object.keys(out).length > 0 ? out : null;
316
294
  }
317
295
  /** Merge a raw `kinds` block over `base` (defaulting to the builtin registry):
318
- * each valid entry adds or shadows a kind by name; invalid entries are
319
- * dropped, never thrown. Mirrors `mergeModelLadders`'s layer-over-defaults
320
- * shape so a user/project config.json can add or override a single kind
321
- * without restating the whole registry. Two entry shapes:
322
- * - FULL (has `whenToUse`): defines or wholly replaces the kind, as before.
323
- * - PATCH (no `whenToUse`, ≥1 valid launch field): field-merges over the
324
- * kind the layers below already define (dropped when they don't) — the
325
- * sparse-override shape `persistDefaultKindModel` writes. */
296
+ * invalid entries are dropped, never thrown. Mirrors `mergeModelLadders`'s
297
+ * layer-over-defaults shape so a config layer can add or override a single
298
+ * kind without restating the whole registry. One rule, no entry shapes:
299
+ * - A kind some lower layer already defines: the entry FIELD-MERGES over it
300
+ * — any subset of fields, `whenToUse` included, so overriding just the
301
+ * spawn-menu guidance of a builtin kind never strips its model tier
302
+ * (the sparse-override shape `persistDefaultKindModel` writes).
303
+ * - A NEW kind: the entry defines it and must carry `whenToUse` (a kind
304
+ * with no spawn-menu gloss is meaningless); dropped otherwise.
305
+ * Deliberately NOT expressible: wholly replacing a lower layer's kind — a
306
+ * field can be overridden but never removed by omission. */
326
307
  function mergeKinds(raw, base = defaultKindsConfig()) {
327
308
  const out = { ...base };
328
309
  if (raw !== null && typeof raw === 'object') {
329
310
  for (const [kind, value] of Object.entries(raw)) {
330
- const normalized = normalizeKindEntry(value);
331
- if (normalized !== null) {
332
- out[kind] = normalized;
311
+ const fields = normalizeKindFields(value);
312
+ if (fields === null)
333
313
  continue;
334
- }
335
314
  const existing = out[kind];
336
- if (existing === undefined)
337
- continue;
338
- const patch = normalizeKindPatch(value);
339
- if (patch !== null)
340
- out[kind] = { ...existing, ...patch };
315
+ if (existing !== undefined) {
316
+ out[kind] = { ...existing, ...fields };
317
+ }
318
+ else if (fields.whenToUse !== undefined) {
319
+ out[kind] = fields;
320
+ }
341
321
  }
342
322
  }
343
323
  return out;
@@ -401,14 +381,14 @@ export function invalidPluginKindsReasons(raw) {
401
381
  reasons.push(`kinds.${kind}.${field} must be an array of strings`);
402
382
  }
403
383
  }
404
- if (normalizeKindEntry(value) === null && normalizeKindPatch(value) === null) {
405
- reasons.push(`kinds.${kind} is neither a full kind (whenToUse required) nor a patch with at least one launch field`);
384
+ if (normalizeKindFields(value) === null) {
385
+ reasons.push(`kinds.${kind} has no valid KindConfig field — an entry must set at least one of: ${[...KIND_CONFIG_KEYS].join(', ')} (whenToUse is required when the kind exists in no lower layer)`);
406
386
  }
407
387
  }
408
388
  return reasons;
409
389
  }
410
390
  /** Validate one raw `remoteCanvas.targets.<name>` entry, or drop it (return
411
- * null) rather than throwing — same rule `normalizeKindEntry` follows. A
391
+ * null) rather than throwing — same rule `normalizeKindFields` follows. A
412
392
  * valid entry needs a non-empty `previewEndpoint` and `relayTokenRef`; the
413
393
  * token itself is never stored here (see `RemoteCanvasTarget`). */
414
394
  function normalizeRemoteCanvasTarget(raw) {
package/dist/types.d.ts CHANGED
@@ -35,9 +35,11 @@ export interface PluginManifest {
35
35
  owner?: OwnerRef;
36
36
  /** Kind-registry contributions, keyed by full kind string (top-level e.g.
37
37
  * `applet-builder`, or sub-kind e.g. `plan/reviewers/security`). Each entry
38
- * is either a FULL `KindConfig` (has `whenToUse`: defines or wholly shadows
39
- * the kind) or a sparse PATCH over a kind a lower layer already defines —
40
- * the same two shapes a scope `config.json` `kinds` block accepts.
38
+ * is any subset of `KindConfig` fields: it FIELD-MERGES over a kind a lower
39
+ * layer already defines (`whenToUse` included — overriding just the spawn
40
+ * guidance never strips the kind's model tier), and defines a NEW kind when
41
+ * none does (then `whenToUse` is required) — the same rule a scope
42
+ * `config.json` `kinds` block follows.
41
43
  * `readMergedLaunchConfig` layers an enabled plugin's entries directly
42
44
  * above the builtin registry and below its host scope's own `config.json`,
43
45
  * so a plugin can ship a persona kind (registry entry here + gated persona
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.187",
3
+ "version": "0.3.188",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.187",
3
+ "version": "0.3.188",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.187",
9
+ "version": "0.3.188",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {