@north-light/crouter 0.3.186 → 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.
@@ -5,6 +5,7 @@ import { BINDING_CATALOG, BINDING_IDS, isAttachPaneBinding } from './keybindings
5
5
  import { emitEvent } from './events/emit.js';
6
6
  import { atomicWriteJson, readJsonIfExists, writeJson, ensureDir } from './fs-utils.js';
7
7
  import { scopeRoot, requireScopeRoot, findProjectScopeRoots } from './scope.js';
8
+ import { listInstalledPluginsInRoot } from './installed-plugins.js';
8
9
  import { profileRoot } from './profiles/manifest.js';
9
10
  import { withExclusiveDirectoryLock } from './exclusive-lock.js';
10
11
  import { SCOPE_CONFIG_KEYS } from './user-settings.js';
@@ -264,42 +265,20 @@ function mergeSpawnEnv(raw, base = []) {
264
265
  }
265
266
  return { allow: [...new Set([...base, ...extra])] };
266
267
  }
267
- /** Validate one raw `kinds.<name>` entry into a `KindConfig`, or drop it
268
- * (return null) rather than throwing — an invalid kind entry in config.json
269
- * must not break config parsing for every other kind. `whenToUse` is the
270
- * only required field. */
271
- function normalizeKindEntry(raw) {
272
- if (raw === null || typeof raw !== 'object')
273
- return null;
274
- const r = raw;
275
- if (typeof r.whenToUse !== 'string' || r.whenToUse.trim() === '')
276
- return null;
277
- const out = { whenToUse: r.whenToUse };
278
- if (typeof r.model === 'string' && r.model.trim() !== '')
279
- out.model = r.model;
280
- if (typeof r.orchestratorModel === 'string' && r.orchestratorModel.trim() !== '')
281
- out.orchestratorModel = r.orchestratorModel;
282
- const isStringArray = (v) => Array.isArray(v) && v.every((item) => typeof item === 'string');
283
- if (isStringArray(r.tools))
284
- out.tools = r.tools;
285
- if (isStringArray(r.extensions))
286
- out.extensions = r.extensions;
287
- if (isStringArray(r.availableTo))
288
- out.availableTo = r.availableTo;
289
- return out;
290
- }
291
- /** Validate one raw `kinds.<name>` entry as a sparse PATCH — an entry with no
292
- * `whenToUse` that overrides individual launch knobs (model/orchestratorModel/
293
- * tools/extensions/availableTo) of a kind some lower-precedence scope already
294
- * defines. This is what lets a scope (e.g. a profile) persist ONLY
295
- * `{ "model": "ultra" }` for a kind instead of freezing a full registry
296
- * snapshot to disk (the same staleness trap `sparseModelLaddersToPersist`
297
- * exists to avoid). Returns null when no valid field is present. */
298
- 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) {
299
276
  if (raw === null || typeof raw !== 'object')
300
277
  return null;
301
278
  const r = raw;
302
279
  const out = {};
280
+ if (typeof r.whenToUse === 'string' && r.whenToUse.trim() !== '')
281
+ out.whenToUse = r.whenToUse;
303
282
  if (typeof r.model === 'string' && r.model.trim() !== '')
304
283
  out.model = r.model;
305
284
  if (typeof r.orchestratorModel === 'string' && r.orchestratorModel.trim() !== '')
@@ -314,35 +293,102 @@ function normalizeKindPatch(raw) {
314
293
  return Object.keys(out).length > 0 ? out : null;
315
294
  }
316
295
  /** Merge a raw `kinds` block over `base` (defaulting to the builtin registry):
317
- * each valid entry adds or shadows a kind by name; invalid entries are
318
- * dropped, never thrown. Mirrors `mergeModelLadders`'s layer-over-defaults
319
- * shape so a user/project config.json can add or override a single kind
320
- * without restating the whole registry. Two entry shapes:
321
- * - FULL (has `whenToUse`): defines or wholly replaces the kind, as before.
322
- * - PATCH (no `whenToUse`, ≥1 valid launch field): field-merges over the
323
- * kind the layers below already define (dropped when they don't) — the
324
- * 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. */
325
307
  function mergeKinds(raw, base = defaultKindsConfig()) {
326
308
  const out = { ...base };
327
309
  if (raw !== null && typeof raw === 'object') {
328
310
  for (const [kind, value] of Object.entries(raw)) {
329
- const normalized = normalizeKindEntry(value);
330
- if (normalized !== null) {
331
- out[kind] = normalized;
311
+ const fields = normalizeKindFields(value);
312
+ if (fields === null)
332
313
  continue;
333
- }
334
314
  const existing = out[kind];
335
- if (existing === undefined)
336
- continue;
337
- const patch = normalizeKindPatch(value);
338
- if (patch !== null)
339
- 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
+ }
340
321
  }
341
322
  }
342
323
  return out;
343
324
  }
325
+ /** Layer the `kinds` contributions of every ENABLED plugin installed under
326
+ * `scopeRootPath` over `base`, plugin name-sorted for determinism (directory
327
+ * listing order is filesystem-dependent). Each plugin's block goes through
328
+ * the same `mergeKinds` a scope `config.json` does — full entries add or
329
+ * shadow, patches field-merge, invalid entries drop. Called by
330
+ * `readMergedLaunchConfig` BELOW the host scope's own raw `kinds`, so the
331
+ * scope's config.json always overrides its plugins. */
332
+ function layerPluginKinds(scope, scopeRootPath, base) {
333
+ if (scopeRootPath === null)
334
+ return base;
335
+ const plugins = listInstalledPluginsInRoot(scope, scopeRootPath)
336
+ .filter((p) => p.enabled && p.manifest.kinds !== undefined)
337
+ .sort((a, b) => a.name.localeCompare(b.name));
338
+ let out = base;
339
+ for (const plugin of plugins)
340
+ out = mergeKinds(plugin.manifest.kinds, out);
341
+ return out;
342
+ }
343
+ const KIND_CONFIG_KEYS = new Set(['whenToUse', 'model', 'orchestratorModel', 'tools', 'extensions', 'availableTo']);
344
+ /** STRICT install-time validation for a plugin-declared `kinds` block —
345
+ * the loud counterpart to `mergeKinds`'s silent read-time dropping. Read
346
+ * paths must never throw on bad config, but an INSTALL delivering a bad
347
+ * block must fail the install, not ship a kind that silently never
348
+ * registers. Returns one human-readable reason per defect; empty = valid.
349
+ * Used by the archive-bundle validator (`command-plugins/bundle.ts`) on
350
+ * `bundle.json`'s optional `kinds` member. */
351
+ export function invalidPluginKindsReasons(raw) {
352
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
353
+ return ['kinds must be a JSON object keyed by kind name'];
354
+ }
355
+ const reasons = [];
356
+ const isStringArray = (v) => Array.isArray(v) && v.every((item) => typeof item === 'string');
357
+ for (const [kind, value] of Object.entries(raw)) {
358
+ if (kind.trim() === '') {
359
+ reasons.push('kinds contains an empty kind name');
360
+ continue;
361
+ }
362
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
363
+ reasons.push(`kinds.${kind} must be a JSON object`);
364
+ continue;
365
+ }
366
+ const entry = value;
367
+ for (const key of Object.keys(entry)) {
368
+ if (!KIND_CONFIG_KEYS.has(key))
369
+ reasons.push(`kinds.${kind}.${key} is not a KindConfig field (expected one of: ${[...KIND_CONFIG_KEYS].join(', ')})`);
370
+ }
371
+ if (entry['whenToUse'] !== undefined && (typeof entry['whenToUse'] !== 'string' || entry['whenToUse'].trim() === '')) {
372
+ reasons.push(`kinds.${kind}.whenToUse must be a non-empty string`);
373
+ }
374
+ for (const field of ['model', 'orchestratorModel']) {
375
+ if (entry[field] !== undefined && (typeof entry[field] !== 'string' || entry[field].trim() === '')) {
376
+ reasons.push(`kinds.${kind}.${field} must be a non-empty string`);
377
+ }
378
+ }
379
+ for (const field of ['tools', 'extensions', 'availableTo']) {
380
+ if (entry[field] !== undefined && !isStringArray(entry[field])) {
381
+ reasons.push(`kinds.${kind}.${field} must be an array of strings`);
382
+ }
383
+ }
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)`);
386
+ }
387
+ }
388
+ return reasons;
389
+ }
344
390
  /** Validate one raw `remoteCanvas.targets.<name>` entry, or drop it (return
345
- * null) rather than throwing — same rule `normalizeKindEntry` follows. A
391
+ * null) rather than throwing — same rule `normalizeKindFields` follows. A
346
392
  * valid entry needs a non-empty `previewEndpoint` and `relayTokenRef`; the
347
393
  * token itself is never stored here (see `RemoteCanvasTarget`). */
348
394
  function normalizeRemoteCanvasTarget(raw) {
@@ -492,17 +538,16 @@ function readRawProfileConfig(profileId) {
492
538
  function readRawProjectScopeConfigs(targetCwd, targetProfileId) {
493
539
  const nearestFirst = findProjectScopeRoots(targetCwd, targetProfileId);
494
540
  const farthestFirst = [...nearestFirst].reverse();
495
- const out = [];
496
- for (const root of farthestFirst) {
497
- const raw = readJsonIfExists(configPathFor(root));
498
- if (raw !== null)
499
- out.push(raw);
500
- }
501
- return out;
541
+ // A root with no config.json still contributes: its installed plugins may
542
+ // declare kinds, so the ROOT rides along and `raw` stays null.
543
+ return farthestFirst.map((root) => ({ root, raw: readJsonIfExists(configPathFor(root)) }));
502
544
  }
503
545
  /** Merge launch knobs (`kinds`, `modelLadders`) across scopes in
504
546
  * project stack > profile > user > builtin precedence — the same precedence
505
- * order used for memory resolution. A kind or ladder cell declared at a
547
+ * order used for memory resolution. For `kinds` only, each plugin-bearing
548
+ * scope (user, each project root) additionally layers its enabled plugins'
549
+ * manifest `kinds` blocks directly BELOW that scope's own raw config — see
550
+ * `layerPluginKinds`. A kind or ladder cell declared at a
506
551
  * more-specific scope shadows the same key from a less-specific scope; the
507
552
  * project STACK (`findProjectScopeRoots` — every ancestor `.crouter/`,
508
553
  * widened by a selected profile's `projects`) layers nearest-root-strongest;
@@ -533,8 +578,13 @@ export function readMergedLaunchConfig(targetCwd = process.cwd(), targetProfileI
533
578
  const defaults = defaultScopeConfig();
534
579
  const userRaw = readRawScopeConfig('user');
535
580
  const profileRaw = readRawProfileConfig(targetProfileId);
536
- const projectRawsFarthestFirst = readRawProjectScopeConfigs(targetCwd, targetProfileId);
537
- let kinds = mergeKinds(userRaw?.kinds, defaults.kinds);
581
+ const projectLayersFarthestFirst = readRawProjectScopeConfigs(targetCwd, targetProfileId);
582
+ // Kinds layer per scope as: that scope's enabled PLUGINS first, then the
583
+ // scope's own raw config — so a plugin can add or shadow a kind (registry
584
+ // entry in its manifest + gated persona docs in its memory tree) and the
585
+ // scope's config.json still overrides its plugins. Other launch knobs
586
+ // (ladders/routes/spawnEnv) have no plugin channel.
587
+ let kinds = mergeKinds(userRaw?.kinds, layerPluginKinds('user', scopeRoot('user'), defaults.kinds));
538
588
  let modelLadders = mergeModelLadders(userRaw?.modelLadders, defaults.modelLadders);
539
589
  let modelRoutes = mergeModelRoutes(userRaw?.modelRoutes);
540
590
  let modelRouting = mergeModelRouting(userRaw?.modelRouting, undefined);
@@ -546,8 +596,10 @@ export function readMergedLaunchConfig(targetCwd = process.cwd(), targetProfileI
546
596
  modelRouting = mergeModelRouting(profileRaw.modelRouting, modelRouting);
547
597
  spawnEnv = mergeSpawnEnv(profileRaw.spawnEnv, spawnEnv.allow);
548
598
  }
549
- for (const raw of projectRawsFarthestFirst) {
550
- kinds = mergeKinds(raw.kinds, kinds);
599
+ for (const { root, raw } of projectLayersFarthestFirst) {
600
+ kinds = mergeKinds(raw?.kinds, layerPluginKinds('project', root, kinds));
601
+ if (raw === null)
602
+ continue;
551
603
  modelLadders = mergeModelLadders(raw.modelLadders, modelLadders);
552
604
  modelRoutes = mergeModelRoutes(raw.modelRoutes, modelRoutes);
553
605
  modelRouting = mergeModelRouting(raw.modelRouting, modelRouting);
@@ -0,0 +1,2 @@
1
+ import type { InstalledPlugin, Scope } from '../types.js';
2
+ export declare function listInstalledPluginsInRoot(scope: Scope, scopeRootPath: string): InstalledPlugin[];
@@ -0,0 +1,42 @@
1
+ // installed-plugins.ts — the LEAF-LEVEL installed-plugin reader: list the
2
+ // plugin packages physically present under one scope root, with their
3
+ // manifests and enabled-state from that root's config.json `plugins` block.
4
+ //
5
+ // This lives below resolver.ts (which re-exports it for its existing callers)
6
+ // so that config.ts can layer plugin-declared launch config (`kinds`) into
7
+ // `readMergedLaunchConfig` without an import cycle: resolver.ts imports
8
+ // config.ts, so config.ts can never import resolver.ts. Imports here stay
9
+ // leaf-safe: types, fs-utils, manifest only.
10
+ import { join } from 'node:path';
11
+ import { CONFIG_FILE } from '../types.js';
12
+ import { listDirs, pathExists, readJsonIfExists } from './fs-utils.js';
13
+ import { readPluginManifest } from './manifest.js';
14
+ function pluginConfigForRoot(root) {
15
+ const cfg = readJsonIfExists(join(root, CONFIG_FILE));
16
+ return cfg && cfg.plugins && typeof cfg.plugins === 'object' ? cfg.plugins : {};
17
+ }
18
+ export function listInstalledPluginsInRoot(scope, scopeRootPath) {
19
+ const dir = join(scopeRootPath, 'plugins');
20
+ if (!pathExists(dir))
21
+ return [];
22
+ const cfg = pluginConfigForRoot(scopeRootPath);
23
+ const out = [];
24
+ for (const name of listDirs(dir)) {
25
+ const root = join(dir, name);
26
+ const manifest = readPluginManifest(root);
27
+ if (!manifest)
28
+ continue;
29
+ const entry = cfg[name];
30
+ const version = typeof entry?.version === 'string' ? entry.version : manifest.version;
31
+ out.push({
32
+ name,
33
+ scope,
34
+ root,
35
+ manifest,
36
+ enabled: typeof entry?.enabled === 'boolean' ? entry.enabled : true,
37
+ sourceMarketplace: typeof entry?.source_marketplace === 'string' ? entry.source_marketplace : undefined,
38
+ version,
39
+ });
40
+ }
41
+ return out;
42
+ }
@@ -1,5 +1,5 @@
1
1
  import type { InstalledMarketplace, InstalledPlugin, Scope } from '../types.js';
2
- export declare function listInstalledPluginsInRoot(scope: Scope, scopeRootPath: string): InstalledPlugin[];
2
+ export { listInstalledPluginsInRoot } from './installed-plugins.js';
3
3
  export declare function listInstalledPlugins(scope: Scope): InstalledPlugin[];
4
4
  export declare function listAllPlugins(): InstalledPlugin[];
5
5
  export declare function findPluginByName(name: string, scope?: Scope): InstalledPlugin | null;
@@ -1,39 +1,14 @@
1
1
  import { join } from 'node:path';
2
- import { CONFIG_FILE } from '../types.js';
3
2
  import { readConfig } from './config.js';
4
- import { listDirs, pathExists, readJsonIfExists } from './fs-utils.js';
5
- import { readMarketplaceManifest, readPluginManifest } from './manifest.js';
3
+ import { listDirs, pathExists } from './fs-utils.js';
4
+ import { readMarketplaceManifest } from './manifest.js';
6
5
  import { InputError } from './io.js';
7
6
  import { marketplacesDir, projectScopeRoot, scopeRoot, userScopeRoot, } from './scope.js';
8
- function pluginConfigForRoot(root) {
9
- const cfg = readJsonIfExists(join(root, CONFIG_FILE));
10
- return cfg && cfg.plugins && typeof cfg.plugins === 'object' ? cfg.plugins : {};
11
- }
12
- export function listInstalledPluginsInRoot(scope, scopeRootPath) {
13
- const dir = join(scopeRootPath, 'plugins');
14
- if (!pathExists(dir))
15
- return [];
16
- const cfg = pluginConfigForRoot(scopeRootPath);
17
- const out = [];
18
- for (const name of listDirs(dir)) {
19
- const root = join(dir, name);
20
- const manifest = readPluginManifest(root);
21
- if (!manifest)
22
- continue;
23
- const entry = cfg[name];
24
- const version = typeof entry?.version === 'string' ? entry.version : manifest.version;
25
- out.push({
26
- name,
27
- scope,
28
- root,
29
- manifest,
30
- enabled: typeof entry?.enabled === 'boolean' ? entry.enabled : true,
31
- sourceMarketplace: typeof entry?.source_marketplace === 'string' ? entry.source_marketplace : undefined,
32
- version,
33
- });
34
- }
35
- return out;
36
- }
7
+ // Moved to installed-plugins.ts (a leaf module below config.ts) so
8
+ // `readMergedLaunchConfig` can layer plugin-declared kinds without an import
9
+ // cycle; re-exported here for this module's existing callers.
10
+ export { listInstalledPluginsInRoot } from './installed-plugins.js';
11
+ import { listInstalledPluginsInRoot } from './installed-plugins.js';
37
12
  export function listInstalledPlugins(scope) {
38
13
  // The builtin scope has no scopeRoot, so this returns [] — builtin content is
39
14
  // the memory substrate, not plugins.
package/dist/types.d.ts CHANGED
@@ -33,7 +33,20 @@ export interface PluginManifest {
33
33
  description?: string;
34
34
  source?: string;
35
35
  owner?: OwnerRef;
36
- kinds?: string[];
36
+ /** Kind-registry contributions, keyed by full kind string (top-level e.g.
37
+ * `applet-builder`, or sub-kind e.g. `plan/reviewers/security`). Each entry
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.
43
+ * `readMergedLaunchConfig` layers an enabled plugin's entries directly
44
+ * above the builtin registry and below its host scope's own `config.json`,
45
+ * so a plugin can ship a persona kind (registry entry here + gated persona
46
+ * memory docs in its `memory/` tree) and the user still overrides it.
47
+ * Archive plugins declare this in `bundle.json`; the installer copies the
48
+ * validated block into this synthesized manifest. */
49
+ kinds?: Record<string, Partial<KindConfig>>;
37
50
  /** Plugin-root-relative path to one declarative command manifest
38
51
  * (`commands.json`). When present on an installed, enabled plugin, that
39
52
  * manifest contributes top-level command branches to the `crtr` tree —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.186",
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.186",
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.186",
9
+ "version": "0.3.188",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {