@north-light/crouter 0.3.220 → 0.3.221

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.
Files changed (148) hide show
  1. package/dist/api/client.d.ts +9 -0
  2. package/dist/api/client.js +10 -0
  3. package/dist/api/dto/chat-inventory.d.ts +43 -0
  4. package/dist/api/dto/chat-inventory.js +11 -0
  5. package/dist/api/dto/profiles.d.ts +19 -5
  6. package/dist/api/dto/profiles.js +2 -1
  7. package/dist/api/index.d.ts +1 -0
  8. package/dist/api/index.js +1 -0
  9. package/dist/api/routes.d.ts +1 -0
  10. package/dist/api/routes.js +1 -0
  11. package/dist/build-root.d.ts +2 -6
  12. package/dist/build-root.js +51 -4
  13. package/dist/builtin-memory/internal/agent-shaping.md +3 -1
  14. package/dist/builtin-memory/internal/memory-loading.md +4 -0
  15. package/dist/builtin-memory/plan/roadmap.md +7 -1
  16. package/dist/builtin-memory/spec/guide.md +7 -1
  17. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/claude-plugin-commands.ts +1 -1
  18. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/crtr-commands/index.ts +7 -1
  19. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +7 -2
  20. package/dist/clients/attach/__tests__/ref-autocomplete.test.js +1 -1
  21. package/dist/clients/attach/__tests__/titled-editor-preview.test.js +1 -1
  22. package/dist/clients/attach/overlays/file-review.js +2 -2
  23. package/dist/clients/attach/session/keys.d.ts +1 -1
  24. package/dist/clients/attach/session/profile-files.js +1 -1
  25. package/dist/clients/attach/viewer.js +690 -690
  26. package/dist/clients/inbox/review/launch.d.ts +8 -4
  27. package/dist/clients/inbox/review/launch.js +55 -5
  28. package/dist/clients/inbox/review/review-client.d.ts +1 -0
  29. package/dist/clients/inbox/review/review-client.js +4 -0
  30. package/dist/clients/inbox/review-adapter.d.ts +1 -8
  31. package/dist/clients/inbox/review-adapter.js +4 -52
  32. package/dist/commands/memory/lint.js +2 -1
  33. package/dist/commands/memory/read.js +1 -0
  34. package/dist/commands/memory.js +1 -1
  35. package/dist/commands/pkg/market-manage.js +165 -75
  36. package/dist/commands/pkg/plugin-inspect.js +19 -2
  37. package/dist/commands/pkg/plugin-manage.d.ts +8 -3
  38. package/dist/commands/pkg/plugin-manage.js +72 -24
  39. package/dist/commands/profile/default.js +6 -10
  40. package/dist/commands/profile/list.js +5 -3
  41. package/dist/commands/profile/new.js +21 -8
  42. package/dist/commands/profile/project.js +25 -19
  43. package/dist/commands/profile/show.js +3 -3
  44. package/dist/commands/surface-inbox.js +1 -0
  45. package/dist/commands/sys/__tests__/migrate.test.js +16 -5
  46. package/dist/commands/sys/doctor.js +35 -5
  47. package/dist/commands/sys/migrate.js +38 -19
  48. package/dist/commands/sys/setup-core.js +1 -1
  49. package/dist/commands/sys/sync-project-guidance.js +1 -1
  50. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +1 -1
  51. package/dist/core/__tests__/fixtures/c5-command-boundary-ext.js +24 -0
  52. package/dist/core/__tests__/fixtures/fake-engine.d.ts +24 -18
  53. package/dist/core/__tests__/fixtures/fake-engine.js +8 -1
  54. package/dist/core/__tests__/inline-memory-refs.test.js +36 -2
  55. package/dist/core/__tests__/profile-project-memory-delivery.test.js +217 -0
  56. package/dist/core/__tests__/serial/broker-sdk-wiring.test.js +102 -2
  57. package/dist/core/bootstrap.js +6 -0
  58. package/dist/core/canvas/browse/app.js +5 -2
  59. package/dist/core/canvas/browse/model.d.ts +25 -15
  60. package/dist/core/canvas/browse/model.js +86 -65
  61. package/dist/core/canvas/render-source.d.ts +6 -0
  62. package/dist/core/canvas/render-source.js +7 -1
  63. package/dist/core/canvas/render.js +10 -2
  64. package/dist/core/command-hooks/artifact.d.ts +10 -0
  65. package/dist/core/command-hooks/artifact.js +129 -0
  66. package/dist/core/command-hooks/catalog.d.ts +14 -0
  67. package/dist/core/command-hooks/catalog.js +38 -0
  68. package/dist/core/command-hooks/compose.d.ts +15 -0
  69. package/dist/core/command-hooks/compose.js +99 -0
  70. package/dist/core/command-hooks/discovery.d.ts +87 -0
  71. package/dist/core/command-hooks/discovery.js +174 -0
  72. package/dist/core/command-hooks/help.d.ts +5 -0
  73. package/dist/core/command-hooks/help.js +18 -0
  74. package/dist/core/command-hooks/index.d.ts +6 -0
  75. package/dist/core/command-hooks/index.js +6 -0
  76. package/dist/core/command-hooks/report.d.ts +23 -0
  77. package/dist/core/command-hooks/report.js +19 -0
  78. package/dist/core/command-hooks/schema.d.ts +27 -0
  79. package/dist/core/command-hooks/schema.js +68 -0
  80. package/dist/core/command-hooks/transport/exec-invoke.d.ts +22 -0
  81. package/dist/core/command-hooks/transport/exec-invoke.js +274 -0
  82. package/dist/core/command-plugins/presence.d.ts +2 -0
  83. package/dist/core/command-plugins/presence.js +17 -0
  84. package/dist/core/command-plugins/transport/exec-invoke.d.ts +5 -0
  85. package/dist/core/command-plugins/transport/exec-invoke.js +58 -5
  86. package/dist/core/command.d.ts +8 -1
  87. package/dist/core/command.js +12 -10
  88. package/dist/core/help.d.ts +7 -1
  89. package/dist/core/io.d.ts +9 -1
  90. package/dist/core/io.js +44 -2
  91. package/dist/core/memory/inline-ref-inventory.d.ts +2 -1
  92. package/dist/core/memory/inline-ref-inventory.js +15 -8
  93. package/dist/core/memory-resolver.d.ts +13 -1
  94. package/dist/core/memory-resolver.js +25 -19
  95. package/dist/core/profiles/manifest.d.ts +13 -2
  96. package/dist/core/profiles/manifest.js +84 -18
  97. package/dist/core/profiles/select.js +9 -9
  98. package/dist/core/render.js +11 -0
  99. package/dist/core/runtime/advertised-command-invocation.d.ts +20 -0
  100. package/dist/core/runtime/advertised-command-invocation.js +233 -0
  101. package/dist/core/runtime/bearings.js +1 -1
  102. package/dist/core/runtime/broker/event-projection.js +7 -0
  103. package/dist/core/runtime/broker/frame-dispatch.d.ts +1 -1
  104. package/dist/core/runtime/broker/frame-dispatch.js +15 -10
  105. package/dist/core/runtime/broker/read-ops.d.ts +4 -0
  106. package/dist/core/runtime/broker/read-ops.js +6 -2
  107. package/dist/core/runtime/broker-extension-render.js +1 -1
  108. package/dist/core/runtime/broker-inventory.d.ts +5 -0
  109. package/dist/core/runtime/broker-inventory.js +191 -0
  110. package/dist/core/runtime/broker-protocol.d.ts +9 -2
  111. package/dist/core/runtime/broker.js +10 -1
  112. package/dist/core/runtime/command-surface.d.ts +33 -0
  113. package/dist/core/runtime/command-surface.js +81 -0
  114. package/dist/core/runtime/node-read.js +5 -0
  115. package/dist/core/scope.d.ts +26 -1
  116. package/dist/core/scope.js +52 -12
  117. package/dist/core/substrate/on-read.d.ts +7 -1
  118. package/dist/core/substrate/on-read.js +13 -4
  119. package/dist/core/substrate/render.js +14 -5
  120. package/dist/core/substrate/schema.d.ts +11 -1
  121. package/dist/core/substrate/schema.js +11 -2
  122. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +4 -4
  123. package/dist/daemon/api/handlers/chat-inventory.d.ts +2 -0
  124. package/dist/daemon/api/handlers/chat-inventory.js +25 -0
  125. package/dist/daemon/api/handlers/profiles.js +7 -1
  126. package/dist/daemon/api/map.d.ts +2 -1
  127. package/dist/daemon/api/map.js +3 -2
  128. package/dist/daemon/api/server.js +2 -0
  129. package/dist/hook-authoring.d.ts +75 -0
  130. package/dist/hook-authoring.js +358 -0
  131. package/dist/hook-process.d.ts +7 -0
  132. package/dist/hook-process.js +34 -0
  133. package/dist/index.d.ts +2 -0
  134. package/dist/index.js +2 -0
  135. package/dist/migrations/002-profile-project-memory.d.ts +2 -0
  136. package/dist/migrations/002-profile-project-memory.js +71 -0
  137. package/dist/migrations/profile-manifests.d.ts +30 -0
  138. package/dist/migrations/profile-manifests.js +70 -0
  139. package/dist/migrations/registry.js +10 -5
  140. package/dist/migrations/types.d.ts +28 -1
  141. package/dist/migrations/types.js +15 -9
  142. package/dist/pi-extensions/__tests__/canvas-structured-output.test.js +21 -4
  143. package/dist/pi-extensions/canvas-structured-output.js +85 -2
  144. package/dist/types.d.ts +15 -6
  145. package/package.json +1 -1
  146. package/runtime.lock.json +2 -2
  147. package/dist/clients/attach/__tests__/file-review-focus.test.js +0 -49
  148. /package/dist/{clients/attach/__tests__/file-review-focus.test.d.ts → core/__tests__/profile-project-memory-delivery.test.d.ts} +0 -0
@@ -25,16 +25,17 @@ function write(path, content) {
25
25
  writeFileSync(path, content);
26
26
  }
27
27
  function manifest(name, projects) {
28
+ const first = projects[0];
28
29
  return JSON.stringify({
29
30
  schema_version: 1,
30
31
  name,
31
32
  projects,
32
- home: projects[0] ?? null,
33
+ home: (typeof first === 'string' ? first : first?.path) ?? null,
33
34
  created_at: '2026-01-01T00:00:00.000Z',
34
35
  last_used_at: null,
35
36
  });
36
37
  }
37
- test('sys migrate covers profile-aware refs and every descendant project/plugin store', () => {
38
+ test('sys migrate converges profile manifests, then profile-aware refs and every descendant project/plugin store', () => {
38
39
  const home = mkdtempSync(join(tmpdir(), 'crtr-sys-migrate-'));
39
40
  const userRoot = join(home, '.crouter');
40
41
  const rootProfile = join(userRoot, 'profiles', 'root-00000000');
@@ -52,8 +53,11 @@ test('sys migrate covers profile-aware refs and every descendant project/plugin
52
53
  const pluginDoc = join(pluginRoot, 'memory', 'plugin.md');
53
54
  const embeddedPluginDoc = join(embeddedPluginRoot, 'memory', 'plugin.md');
54
55
  try {
55
- write(join(rootProfile, 'profile.json'), manifest('root', []));
56
- write(join(appletProfile, 'profile.json'), manifest('applet-inactive', [appletProject, nestedOnlyProject]));
56
+ // Root's string entries migrate to `preview`; every other profile's to
57
+ // `content`. The applet manifest is mixed: its already-current entry must
58
+ // survive untouched while the string beside it converts.
59
+ write(join(rootProfile, 'profile.json'), manifest('root', [appletProject]));
60
+ write(join(appletProfile, 'profile.json'), manifest('applet-inactive', [{ path: appletProject, memory: 'name' }, nestedOnlyProject]));
57
61
  write(userDoc, `${OLD_DOC}\nSee [[user/scoped]].\n`);
58
62
  write(join(userRoot, 'memory', 'scoped', 'INDEX.md'), CURRENT_DOC);
59
63
  write(rootDoc, `${OLD_DOC}\nSee [[shared]].\n`);
@@ -78,8 +82,15 @@ test('sys migrate covers profile-aware refs and every descendant project/plugin
78
82
  const result = spawnSync(process.execPath, ['--conditions=crtr-src', '--import', TSX_LOADER, CLI, 'sys', 'migrate', '--json'], { cwd: home, env, encoding: 'utf8' });
79
83
  assert.equal(result.status, 0, result.stderr);
80
84
  const output = JSON.parse(result.stdout);
85
+ assert.equal(output.profiles, 2);
81
86
  assert.equal(output.stores, 7);
82
- assert.equal(output.changed, 7);
87
+ assert.equal(output.changed, 9);
88
+ const readProjects = (dir) => JSON.parse(readFileSync(join(dir, 'profile.json'), 'utf8')).projects;
89
+ assert.deepEqual(readProjects(rootProfile), [{ path: appletProject, memory: 'preview' }]);
90
+ assert.deepEqual(readProjects(appletProfile), [
91
+ { path: appletProject, memory: 'name' },
92
+ { path: nestedOnlyProject, memory: 'content' },
93
+ ]);
83
94
  for (const path of [userDoc, rootDoc, appletDoc, projectDoc, deepDoc, pluginDoc, embeddedPluginDoc]) {
84
95
  const migrated = readFileSync(path, 'utf8');
85
96
  assert.doesNotMatch(migrated, /system-prompt-visibility|file-read-visibility/);
@@ -13,10 +13,11 @@ import { readFault } from '../../core/runtime/fault.js';
13
13
  import { detectNerdFont, nerdFontInstallCommand, NERD_FONT_SELECT_HINT } from '../../core/runtime/nerd-font.js';
14
14
  import { detectPackageManager } from './setup-core.js';
15
15
  import { validateEffectiveCommandPlugins } from '../../core/command-plugins/discovery.js';
16
+ import { discoverHookRegistry } from '../../core/command-hooks/discovery.js';
16
17
  import { resolveBinContributions } from '../../core/runtime/bin-contributions.js';
17
18
  import { buildBrokerEnv, resolvePathExecutable } from '../../core/runtime/spawn-env.js';
18
19
  import { listAllPlugins } from '../../core/resolver.js';
19
- import { SUBTREE_NAMES, coreCommandPaths } from '../../build-root.js';
20
+ import { SUBTREE_NAMES, coreCommandPaths, coreHookCatalog } from '../../build-root.js';
20
21
  import { processGeneration, processLocatedGeneration, runtimeManifest, selectedGeneration } from './shared.js';
21
22
  function pass(scope, name, message) {
22
23
  return { scope, name, status: 'pass', message };
@@ -141,6 +142,34 @@ async function runCommandPluginChecks(scopes) {
141
142
  }
142
143
  return results;
143
144
  }
145
+ /** Statically inspect the complete effective hook registry. This shares the
146
+ * runtime target catalog but never enters hook execution, preserving doctor as
147
+ * the recovery path for broken hooks. */
148
+ async function runHookPluginChecks(scopes) {
149
+ const inScope = new Set(scopes);
150
+ const registry = discoverHookRegistry(await coreHookCatalog());
151
+ const results = [];
152
+ for (const validation of registry.validations) {
153
+ const plugin = validation.plugin;
154
+ if (!inScope.has(plugin.scope))
155
+ continue;
156
+ const issues = registry.issues.filter((issue) => issue.plugin === plugin.name && issue.scope === plugin.scope);
157
+ if (issues.length === 0) {
158
+ const declarations = validation.hooks.map((hook) => `${hook.target} [${hook.phase} ${hook.op}]`).join('; ');
159
+ results.push(pass(plugin.scope, `plugin:${plugin.name}:hooks`, `effective hook declaration(s): ${declarations}`));
160
+ continue;
161
+ }
162
+ issues.forEach((issue, index) => {
163
+ const source = validation.manifestPath ?? join(plugin.root, '.crouter-plugin', 'plugin.json');
164
+ const detail = issue.path !== undefined ? `${source}:${issue.path}` : source;
165
+ results.push(failCheck(plugin.scope, `plugin:${plugin.name}:hooks[${index}]`, `${issue.code} at ${detail}: ${issue.message} (received ${issue.received}, expected ${issue.expected})`, {
166
+ kind: 'human_action',
167
+ description: `${issue.next} Inspect with \`crtr pkg plugin show ${plugin.name}\`; then disable (\`crtr pkg plugin disable ${plugin.name} --scope ${plugin.scope}\`), update (\`crtr pkg plugin update --name ${plugin.name}\`), or remove (\`crtr pkg plugin remove ${plugin.name}\`) the plugin.`,
168
+ }));
169
+ });
170
+ }
171
+ return results;
172
+ }
144
173
  /**
145
174
  * Report every bare-binary contribution (`bin` in a plugin manifest or a scope
146
175
  * `config.json`) that failed to reach a node's PATH, plus the ones that did.
@@ -467,7 +496,7 @@ function runChecksForScope(scope, opts) {
467
496
  export const sysDoctorLeaf = defineLeaf({
468
497
  name: 'doctor',
469
498
  description: 'diagnose installation health',
470
- whenToUse: 'something in your crtr install looks off and you want it diagnosed — a plugin or marketplace manifest is missing, a config entry points at a directory that no longer exists, or a plugin command manifest/transport is malformed. Reports each problem with a structured remediation, and can apply the safe repairs for you.',
499
+ whenToUse: 'something in your crtr install looks off and you want it diagnosed — a plugin or marketplace manifest is missing, a config entry points at a directory that no longer exists, or a plugin command/hook declaration is malformed. Reports each problem with a structured remediation, and can apply the safe repairs for you.',
471
500
  help: {
472
501
  name: 'sys doctor',
473
502
  summary: 'diagnose runtime integrity, missing manifests, and broken config entries',
@@ -477,13 +506,13 @@ export const sysDoctorLeaf = defineLeaf({
477
506
  { kind: 'flag', name: 'remote', type: 'bool', required: false, constraint: 'Check git remotes with ls-remote (slow — makes network calls).' },
478
507
  ],
479
508
  output: [
480
- { name: 'checks', type: 'object[]', required: true, constraint: 'Each: {scope, name, status, message, fixed?, remediation?}. status: pass | fail. scope may be user | project | canvas. bin:<name> checks report bare-binary contributions (a `bin` block in a plugin manifest or a scope config.json): a pass names the contributor and the resolved target, a fail names an unsafe/reserved command name, malformed target declaration, missing/non-executable/escaping target, or same-precedence name collision that installed nothing; their remediation is always a human_action. requires:<name> checks report each enabled plugin manifest PATH requirement: a pass names the plugin and resolved executable path; a fail names the plugin and carries its declared install hint as remediation. Runtime checks validate selected and process generation manifests, sealing/containment, and full SHA-256 inventories. terminal:nerd-font (user scope) reports whether a Nerd Font is installed for the viewer\'s icon glyphs; its remediation is always a human_action carrying the platform install command, never auto-fixed. Plugin manifest checks appear as plugin:<name>:commands (validated statically, never executed); their remediation is always a human_action (disable/update/remove the plugin). remediation (when present) is {kind, description, ...payload} where kind is remove_config_key | rm_path | human_action; fault human_action remediations include the canonical logs command. Sorted by scope then name.' },
509
+ { name: 'checks', type: 'object[]', required: true, constraint: 'Each: {scope, name, status, message, fixed?, remediation?}. status: pass | fail. scope may be user | project | canvas. bin:<name> checks report bare-binary contributions (a `bin` block in a plugin manifest or a scope config.json): a pass names the contributor and the resolved target, a fail names an unsafe/reserved command name, malformed target declaration, missing/non-executable/escaping target, or same-precedence name collision that installed nothing; their remediation is always a human_action. requires:<name> checks report each enabled plugin manifest PATH requirement: a pass names the plugin and resolved executable path; a fail names the plugin and carries its declared install hint as remediation. Runtime checks validate selected and process generation manifests, sealing/containment, and full SHA-256 inventories. terminal:nerd-font (user scope) reports whether a Nerd Font is installed for the viewer\'s icon glyphs; its remediation is always a human_action carrying the platform install command, never auto-fixed. Plugin checks appear as plugin:<name>:commands and plugin:<name>:hooks; both are validated statically and never execute plugin code. Hook failures identify manifest/path/target/collision problems and carry disable/update/remove/show remediation; healthy hooks list effective target, phase, and op. remediation (when present) is {kind, description, ...payload} where kind is remove_config_key | rm_path | human_action; fault human_action remediations include the canonical logs command. Sorted by scope then name.' },
481
510
  { name: 'ok', type: 'boolean', required: true, constraint: 'True when no unresolved fail checks remain.' },
482
511
  ],
483
512
  outputKind: 'object',
484
513
  effects: [
485
- 'Read-only unless --fix is passed. Runtime verification reads and hashes every selected/process generation file; plugin command manifests, bare-binary (`bin`) declarations, and advisory `requires` PATH checks never execute plugin or contributed binaries.',
486
- 'With --fix: applies each non-pass check\'s `remediation` — removes stale config entries and deletes dangling plugin/marketplace directories; canvas fault findings and plugin command findings stay read-only (--fix never chmods or rewrites third-party plugin content).',
514
+ 'Read-only unless --fix is passed. Runtime verification reads and hashes every selected/process generation file; plugin command and hook manifests, bare-binary (`bin`) declarations, and advisory `requires` PATH checks never execute plugin or contributed binaries.',
515
+ 'With --fix: applies each non-pass check\'s `remediation` — removes stale config entries and deletes dangling plugin/marketplace directories; canvas fault findings and plugin command/hook findings stay read-only (--fix never chmods or rewrites third-party plugin content).',
487
516
  'Each non-pass result carries a structured `remediation` describing the fix action (absolute paths, exact config keys, or a human_action with its command) so callers can apply it directly without --fix.',
488
517
  ],
489
518
  },
@@ -500,6 +529,7 @@ export const sysDoctorLeaf = defineLeaf({
500
529
  if (scopes.includes('user'))
501
530
  allResults.push(runNerdFontCheck());
502
531
  allResults.push(...await runCommandPluginChecks(scopes));
532
+ allResults.push(...await runHookPluginChecks(scopes));
503
533
  allResults.push(...runBinContributionChecks(scopes));
504
534
  allResults.push(...runRequiresChecks(scopes));
505
535
  allResults.push(...await runFaultChecks());
@@ -1,24 +1,32 @@
1
- // `crtr sys migrate` — the convergent-lane state-migration runner: converge
2
- // every writable memory store's documents to the current document format.
3
- // (Journaled canvas-db migrations run in the db's own ordered chain when the
4
- // daemon opens it; this leaf owns only the on-disk document stores.)
1
+ // `crtr sys migrate` — the on-disk state-migration runner: converge every
2
+ // stored profile manifest to the current manifest format, then every writable
3
+ // memory store's documents to the current document format. (Journaled
4
+ // canvas-db migrations run in the db's own ordered chain when the daemon opens
5
+ // it; this leaf owns only the on-disk state.)
6
+ //
7
+ // Manifests come FIRST and are read as raw bytes under the profile lock: the
8
+ // current reader treats a pre-migration manifest exactly like corrupt JSON, so
9
+ // `listProfiles()` sees nothing until this lane has run. Store discovery below
10
+ // therefore works from the lane's post-migration snapshots — which are equally
11
+ // coherent under `--dry-run`, where the same fold ran and nothing was written.
5
12
  import { dirname, join } from 'node:path';
6
13
  import { defineLeaf } from '../../core/command.js';
7
14
  import { pathExists, realpathOrSelf } from '../../core/fs-utils.js';
8
15
  import { createMemoryDocSnapshot } from '../../core/memory-resolver.js';
9
16
  import { listInstalledPlugins, listInstalledPluginsInRoot } from '../../core/resolver.js';
10
17
  import { pluginMemoryDir, projectScopeRoots, scopeMemoryDir } from '../../core/scope.js';
11
- import { listProfiles, profileMemoryDir } from '../../core/profiles/manifest.js';
18
+ import { profileMemoryDir, profilesRoot, withProfileManifestLock } from '../../core/profiles/manifest.js';
12
19
  import { descendantStoreRootsExhaustive } from '../../core/nested-stores.js';
13
20
  import { memoryDir as nodeMemoryDir } from '../../core/runtime/memory.js';
14
21
  import { runConvergentMigrations } from '../../migrations/convergent.js';
22
+ import { runProfileManifestMigrations } from '../../migrations/profile-manifests.js';
15
23
  function targetKey(target) {
16
24
  return `${realpathOrSelf(target.cwd)}\u0000${target.profileId ?? ''}\u0000${target.nodeId ?? ''}`;
17
25
  }
18
26
  /** Every writable memory store — the lint corpus minus builtin (package-owned,
19
27
  * regenerated on every install, so migrating it would fight the package).
20
28
  * Physical stores dedupe once while retaining every owning target corpus. */
21
- function migratableStores() {
29
+ function migratableStores(profiles) {
22
30
  const stores = new Map();
23
31
  const add = (dir, target) => {
24
32
  if (dir === null || dir === '' || !pathExists(dir))
@@ -49,18 +57,18 @@ function migratableStores() {
49
57
  };
50
58
  const selectedProfileId = process.env['CRTR_PROFILE_ID'] || null;
51
59
  addProjectRoots(projectScopeRoots(process.cwd(), null), selectedProfileId);
52
- const profiles = listProfiles();
53
60
  const profileTargets = [];
54
61
  for (const { profileId, manifest } of profiles) {
62
+ const home = manifest.home !== null && manifest.home !== '' ? manifest.home : null;
55
63
  const profileTarget = {
56
- cwd: manifest.home ?? manifest.projects[0] ?? process.cwd(),
64
+ cwd: home ?? manifest.projects[0]?.path ?? process.cwd(),
57
65
  profileId,
58
66
  nodeId: null,
59
67
  };
60
68
  profileTargets.push(profileTarget);
61
69
  add(profileMemoryDir(profileId), profileTarget);
62
70
  const roots = [
63
- ...new Set(manifest.projects.flatMap((project) => [join(project, '.crouter'), ...projectScopeRoots(project, null)])),
71
+ ...new Set(manifest.projects.flatMap((project) => [join(project.path, '.crouter'), ...projectScopeRoots(project.path, null)])),
64
72
  ];
65
73
  addProjectRoots(roots, profileId);
66
74
  }
@@ -82,23 +90,25 @@ function migratableStores() {
82
90
  }
83
91
  export const sysMigrateLeaf = defineLeaf({
84
92
  name: 'migrate',
85
- description: 'converge every writable memory store to the current document format',
86
- whenToUse: 'after a crtr update whose release notes name a document-format migration, or when old-format docs resurface (a restore, a sync, a hand-authored file). Convergent and idempotent: re-running over a current corpus changes nothing, so use --dry-run first only when you want to review the rewrite.',
93
+ description: 'converge every profile manifest and writable memory store to the current on-disk format',
94
+ whenToUse: 'after a crtr update whose release notes name a state migration, when profiles stop resolving on a home that predates the update, or when old-format docs resurface (a restore, a sync, a hand-authored file). Convergent and idempotent: re-running over current state changes nothing, so use --dry-run first only when you want to review the rewrite.',
87
95
  help: {
88
96
  name: 'sys migrate',
89
- summary: 'run the registered document-format migrations over every writable memory store',
97
+ summary: 'run the registered state migrations over every profile manifest and writable memory store',
90
98
  params: [
91
99
  { kind: 'flag', name: 'dry-run', type: 'bool', required: false, constraint: 'Plan and report every rewrite without writing any file.' },
92
100
  ],
93
101
  output: [
102
+ { name: 'profiles', type: 'number', required: true, constraint: 'Profile manifests scanned.' },
94
103
  { name: 'stores', type: 'number', required: true, constraint: 'Memory stores scanned.' },
95
- { name: 'changed', type: 'number', required: true, constraint: 'Files rewritten (or, under --dry-run, that would be).' },
96
- { name: 'applied', type: 'object[]', required: true, constraint: 'One row per migration that changed files in a store: {store, migration, files}. Empty when the corpus is already current.' },
97
- { name: 'skipped', type: 'object[]', required: true, constraint: 'Docs left untouched because their frontmatter is not valid YAML: {store, relPath, error}. Fix the doc, then re-run.' },
104
+ { name: 'changed', type: 'number', required: true, constraint: 'Files rewritten (or, under --dry-run, that would be) — manifests and documents together.' },
105
+ { name: 'applied', type: 'object[]', required: true, constraint: 'One row per migration that changed files: {store, lane, migration, files}, lane one of profile-manifest, convergent. Empty when the state is already current.' },
106
+ { name: 'skipped', type: 'object[]', required: true, constraint: 'Files left untouched because they could not be parsed — a doc whose frontmatter is not valid YAML, or a manifest that is not valid JSON: {store, relPath, error}. Fix the file, then re-run.' },
98
107
  { name: 'dryRun', type: 'boolean', required: true, constraint: 'True when nothing was written (preview only).' },
99
108
  ],
100
109
  outputKind: 'object',
101
110
  effects: [
111
+ 'Rewrites old-format `profile.json` manifests in place (atomic, under the same per-profile lock `profile project add/remove` take). A manifest whose structure cannot be converted fails the whole run — no project setting is ever guessed.',
102
112
  'Rewrites old-format memory documents in place (atomic per-file writes) across the writable stores: every profile and every profile-owned project (plus nested descendant stores and installed plugin stores), the current project, user, and this node\'s store. Builtin docs are package-owned and never touched.',
103
113
  'Under --dry-run: read-only, writes nothing.',
104
114
  ],
@@ -107,7 +117,15 @@ export const sysMigrateLeaf = defineLeaf({
107
117
  const dryRun = input.dryRun === true;
108
118
  const applied = [];
109
119
  const skipped = [];
110
- const stores = migratableStores();
120
+ const profilesDir = profilesRoot();
121
+ const profileRun = runProfileManifestMigrations(profilesDir, { dryRun, withLock: withProfileManifestLock });
122
+ for (const row of profileRun.applied) {
123
+ applied.push({ store: profilesDir, lane: 'profile-manifest', migration: row.migration, files: [row.path] });
124
+ }
125
+ for (const row of profileRun.skipped) {
126
+ skipped.push({ store: profilesDir, relPath: `${row.profileId}/profile.json`, error: row.error });
127
+ }
128
+ const stores = migratableStores(profileRun.snapshots);
111
129
  const snapshots = new Map();
112
130
  for (const store of stores) {
113
131
  const resolvers = store.targets.map((target) => (name) => {
@@ -137,21 +155,22 @@ export const sysMigrateLeaf = defineLeaf({
137
155
  },
138
156
  });
139
157
  for (const row of result.applied)
140
- applied.push({ store: store.dir, migration: row.migration, files: row.files });
158
+ applied.push({ store: store.dir, lane: 'convergent', migration: row.migration, files: row.files });
141
159
  for (const s of result.skipped)
142
160
  skipped.push({ store: store.dir, relPath: s.relPath, error: s.error });
143
161
  }
144
162
  const changed = applied.reduce((n, row) => n + row.files.length, 0);
145
163
  return {
164
+ profiles: profileRun.profiles,
146
165
  stores: stores.length,
147
166
  changed,
148
167
  applied,
149
168
  skipped,
150
169
  dryRun,
151
170
  follow_up: skipped.length > 0
152
- ? 'Some docs have invalid YAML frontmatter and were left untouched — fix each named file, then re-run `crtr sys migrate`.'
171
+ ? 'Some files could not be parsed and were left untouched — fix each named file, then re-run `crtr sys migrate`.'
153
172
  : changed === 0
154
- ? 'Corpus already current — nothing to rewrite.'
173
+ ? 'State already current — nothing to rewrite.'
155
174
  : dryRun
156
175
  ? 'Preview only — re-run without --dry-run to write these changes.'
157
176
  : undefined,
@@ -535,7 +535,7 @@ export async function executeSetupSubmission(submission) {
535
535
  const registered = ensureMarketplaceRegistered(marketplace);
536
536
  if (registered !== null)
537
537
  installedPackages.push(registered);
538
- installFromMarketplace({ marketplace: marketplace, plugin: plugin }, 'user');
538
+ await installFromMarketplace({ marketplace: marketplace, plugin: plugin }, 'user');
539
539
  installedPackages.push(entry.name);
540
540
  }
541
541
  catch (e) {
@@ -36,7 +36,7 @@ function projectDocRoots(sourceArg) {
36
36
  try {
37
37
  const { manifest } = loadProfileManifest(profileId);
38
38
  for (const p of manifest.projects) {
39
- const r = resolve(p);
39
+ const r = resolve(p.path);
40
40
  if (!roots.includes(r))
41
41
  roots.push(r);
42
42
  }
@@ -271,7 +271,7 @@ describe('canvas inbox watcher — hold + idle delivery', () => {
271
271
  homes.push(userHome);
272
272
  process.env.HOME = userHome;
273
273
  try {
274
- const profile = createProfile('paused watcher', ['/tmp']);
274
+ const profile = createProfile('paused watcher', [{ path: '/tmp', memory: 'content' }]);
275
275
  pauseProfile(profile.profileId);
276
276
  process.env['CRTR_PROFILE_ID'] = profile.profileId;
277
277
  const pi = makeFakePi();
@@ -5,4 +5,28 @@ export default function (pi) {
5
5
  /* never invoked — isLeadingEngineCommand only checks registration */
6
6
  },
7
7
  });
8
+ pi.registerCommand('gatewaycmd', {
9
+ description: 'Gateway-advertised custom-message fixture',
10
+ gateway: true,
11
+ handler: async () => {
12
+ pi.sendMessage({
13
+ customType: 'fixture-output',
14
+ content: 'expanded agent input',
15
+ display: true,
16
+ details: { fixture: true },
17
+ });
18
+ },
19
+ });
20
+ pi.registerCommand('gatewaynext', {
21
+ description: 'Queued Gateway custom-message fixture',
22
+ gateway: true,
23
+ handler: async () => {
24
+ pi.sendMessage({
25
+ customType: 'fixture-next-output',
26
+ content: null,
27
+ display: true,
28
+ details: 'extension-details',
29
+ }, { deliverAs: 'nextTurn' });
30
+ },
31
+ });
8
32
  }
@@ -107,6 +107,28 @@ interface SessionTreeNode {
107
107
  entry: TreeEntry;
108
108
  children: SessionTreeNode[];
109
109
  }
110
+ /** The real SessionManager surface the broker reads and patches. A method
111
+ * missing here is a broken fixture, not a degraded one — the broker throws
112
+ * during session build and never boots. */
113
+ interface FakeSessionManager {
114
+ buildSessionContext: () => {
115
+ messages: AgentMessage[];
116
+ thinkingLevel: string;
117
+ model: null;
118
+ };
119
+ getLeafId: () => string | null;
120
+ getEntry: (id: string) => TreeEntry | undefined;
121
+ getTree: () => SessionTreeNode[];
122
+ getUserMessagesForForking: () => Array<{
123
+ entryId: string;
124
+ text: string;
125
+ timestamp?: string;
126
+ }>;
127
+ getSessionId: () => string;
128
+ getSessionFile: () => string;
129
+ getSessionDir: () => string;
130
+ appendCustomMessageEntry: (customType: string, content: unknown, display: boolean, details?: unknown) => string;
131
+ }
110
132
  declare class FakeSession {
111
133
  private readonly sm;
112
134
  private readonly loader;
@@ -142,24 +164,8 @@ declare class FakeSession {
142
164
  private readonly modelRt;
143
165
  constructor(sm: SessionManager, loader: ResourceLoader | undefined, model?: FakeModel, modelRuntime?: FakeModelRuntime);
144
166
  get messages(): AgentMessage[];
145
- get sessionManager(): {
146
- buildSessionContext: () => {
147
- messages: AgentMessage[];
148
- thinkingLevel: string;
149
- model: null;
150
- };
151
- getLeafId: () => string | null;
152
- getEntry: (id: string) => TreeEntry | undefined;
153
- getTree: () => SessionTreeNode[];
154
- getUserMessagesForForking: () => Array<{
155
- entryId: string;
156
- text: string;
157
- timestamp?: string;
158
- }>;
159
- getSessionId: () => string;
160
- getSessionFile: () => string;
161
- getSessionDir: () => string;
162
- };
167
+ private smFacade;
168
+ get sessionManager(): FakeSessionManager;
163
169
  get sessionId(): string;
164
170
  get sessionFile(): string | undefined;
165
171
  get model(): FakeModel | undefined;
@@ -341,8 +341,13 @@ class FakeSession {
341
341
  // truth, since `session.messages` can be sliced by pi's retry/overflow/
342
342
  // compaction recovery. Mirror that surface so the fake's snapshot still serves
343
343
  // the accrued history (G2/G3). The fake's persisted analog is the active branch.
344
+ // Built once and cached: the real SessionManager is a single object the
345
+ // broker's advertised-command contract monkey-patches
346
+ // (appendCustomMessageEntry), so a fresh literal per access would silently
347
+ // discard that patch.
348
+ smFacade;
344
349
  get sessionManager() {
345
- return {
350
+ this.smFacade ??= {
346
351
  buildSessionContext: () => this.tree.buildSessionContext(),
347
352
  getLeafId: () => this.tree.getLeafId(),
348
353
  getEntry: (id) => this.tree.getEntry(id),
@@ -351,7 +356,9 @@ class FakeSession {
351
356
  getSessionId: () => this.sm.getSessionId(),
352
357
  getSessionFile: () => this.sm.getSessionFile(),
353
358
  getSessionDir: () => this.sm.getSessionDir(),
359
+ appendCustomMessageEntry: (customType, content, display, details) => this.tree.append({ role: 'custom', customType, content, display, details }).id,
354
360
  };
361
+ return this.smFacade;
355
362
  }
356
363
  get sessionId() {
357
364
  return this.sm.getSessionId();
@@ -14,6 +14,7 @@ import { resetScopeCache } from '../scope.js';
14
14
  import { clearSessionCache } from '../substrate/session-cache.js';
15
15
  import { memoryDir as nodeMemoryDir } from '../runtime/memory.js';
16
16
  import { buildRefInventory } from '../memory/inline-ref-inventory.js';
17
+ import { gatewayMemoryRows } from '../runtime/broker-inventory.js';
17
18
  // ---------------------------------------------------------------------------
18
19
  // Wave C4 — buildRefInventory: the authoritative corpus builder. Fixture-
19
20
  // backed (node/project/user scope docs on disk), since precedence dedup,
@@ -47,6 +48,21 @@ describe('buildRefInventory (fixture-backed corpus)', () => {
47
48
  function nodeScopeMemoryDir() {
48
49
  return nodeMemoryDir(NODE_ID);
49
50
  }
51
+ function plantNorthlightVisibilityExtension() {
52
+ const manifestDir = join(userHome, '.crouter', 'plugins', 'northlight', '.crouter-plugin');
53
+ mkdirSync(manifestDir, { recursive: true });
54
+ writeFileSync(join(manifestDir, 'plugin.json'), JSON.stringify({
55
+ name: 'northlight',
56
+ memory_extensions: {
57
+ visibility: {
58
+ type: 'boolean',
59
+ default: true,
60
+ write_help: 'Control chat visibility.',
61
+ edit_help: 'Control chat visibility.',
62
+ },
63
+ },
64
+ }));
65
+ }
50
66
  before(() => {
51
67
  prevCanvasHome = process.env['CRTR_HOME'];
52
68
  prevUserHome = process.env['HOME'];
@@ -148,12 +164,30 @@ describe('buildRefInventory (fixture-backed corpus)', () => {
148
164
  // A sibling doc in the same corpus is unaffected.
149
165
  assert.ok(names.has('crtr-inline-ref-fixture-sibling'));
150
166
  });
151
- test('refs are metadata-only: exactly {name, kind, scope, shortForm}, no body/path', () => {
167
+ test('refs are metadata-only: exactly {name, kind, scope, shortForm, gatewayVisible}, no body/path', () => {
152
168
  writeDoc(projectMemoryDir(), 'crtr-inline-ref-fixture-meta.md', "kind: knowledge\nshort-form: 'hook'");
153
169
  const { refs } = buildRefInventory();
154
170
  const ref = refs.find((r) => r.name === 'crtr-inline-ref-fixture-meta');
155
171
  assert.ok(ref);
156
- assert.deepEqual(Object.keys(ref).sort(), ['kind', 'name', 'scope', 'shortForm']);
172
+ assert.deepEqual(Object.keys(ref).sort(), ['gatewayVisible', 'kind', 'name', 'scope', 'shortForm']);
173
+ });
174
+ test('Gateway eligibility excludes builtins and effective Northlight-hidden refs without changing resolution', () => {
175
+ plantNorthlightVisibilityExtension();
176
+ writeDoc(projectMemoryDir(), 'crtr-inline-ref-fixture-hidden.md', 'kind: knowledge\nextensions:\n northlight:\n visibility: false');
177
+ writeDoc(projectMemoryDir(), 'crtr-inline-ref-fixture-visible.md', 'kind: knowledge');
178
+ const { refs, names } = buildRefInventory();
179
+ const hidden = refs.find((ref) => ref.name === 'crtr-inline-ref-fixture-hidden');
180
+ const visible = refs.find((ref) => ref.name === 'crtr-inline-ref-fixture-visible');
181
+ assert.equal(hidden?.gatewayVisible, false);
182
+ assert.equal(visible?.gatewayVisible, true);
183
+ assert.ok(names.has('crtr-inline-ref-fixture-hidden'));
184
+ assert.ok(names.has('crtr-inline-ref-fixture-visible'));
185
+ assert.ok(refs.some((ref) => ref.scope === 'builtin'));
186
+ assert.ok(refs.filter((ref) => ref.scope === 'builtin').every((ref) => !ref.gatewayVisible));
187
+ const chatRows = gatewayMemoryRows(refs);
188
+ assert.ok(!chatRows.some((ref) => ref.name === 'crtr-inline-ref-fixture-hidden'));
189
+ assert.ok(chatRows.some((ref) => ref.name === 'crtr-inline-ref-fixture-visible'));
190
+ assert.ok(!chatRows.some((ref) => ref.scope === 'builtin'));
157
191
  });
158
192
  test('a malformed doc (no valid kind) is omitted from the inventory', () => {
159
193
  writeDoc(projectMemoryDir(), 'crtr-inline-ref-fixture-malformed.md', "short-form: 'no kind here'");