@north-light/crouter 0.3.210 → 0.3.212

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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,95 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import test from 'node:test';
8
+ const HERE = dirname(fileURLToPath(import.meta.url));
9
+ const CLI = join(HERE, '../../../cli.ts');
10
+ const TSX_LOADER = import.meta.resolve('tsx/esm');
11
+ const OLD_DOC = `---
12
+ kind: knowledge
13
+ system-prompt-visibility: content
14
+ file-read-visibility: none
15
+ ---
16
+ # Old format
17
+ `;
18
+ const CURRENT_DOC = `---
19
+ kind: knowledge
20
+ ---
21
+ # Current format
22
+ `;
23
+ function write(path, content) {
24
+ mkdirSync(dirname(path), { recursive: true });
25
+ writeFileSync(path, content);
26
+ }
27
+ function manifest(name, projects) {
28
+ return JSON.stringify({
29
+ schema_version: 1,
30
+ name,
31
+ projects,
32
+ home: projects[0] ?? null,
33
+ created_at: '2026-01-01T00:00:00.000Z',
34
+ last_used_at: null,
35
+ });
36
+ }
37
+ test('sys migrate covers profile-aware refs and every descendant project/plugin store', () => {
38
+ const home = mkdtempSync(join(tmpdir(), 'crtr-sys-migrate-'));
39
+ const userRoot = join(home, '.crouter');
40
+ const rootProfile = join(userRoot, 'profiles', 'root-00000000');
41
+ const appletProfile = join(userRoot, 'profiles', 'applet-deadbeef');
42
+ const appletProject = join(home, 'inactive-applet-project');
43
+ const nestedOnlyProject = join(home, 'nested-only-project');
44
+ const deepRoot = join(appletProject, 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', '.crouter');
45
+ const pluginRoot = join(deepRoot, 'plugins', 'deep-memory');
46
+ const embeddedPluginRoot = join(nestedOnlyProject, 'embedded', '.crouter', 'plugins', 'embedded-memory');
47
+ const userDoc = join(userRoot, 'memory', 'user-link.md');
48
+ const rootDoc = join(rootProfile, 'memory', 'root.md');
49
+ const appletDoc = join(appletProfile, 'memory', 'identity.md');
50
+ const projectDoc = join(appletProject, '.crouter', 'memory', 'project.md');
51
+ const deepDoc = join(deepRoot, 'memory', 'deep.md');
52
+ const pluginDoc = join(pluginRoot, 'memory', 'plugin.md');
53
+ const embeddedPluginDoc = join(embeddedPluginRoot, 'memory', 'plugin.md');
54
+ try {
55
+ write(join(rootProfile, 'profile.json'), manifest('root', []));
56
+ write(join(appletProfile, 'profile.json'), manifest('applet-inactive', [appletProject, nestedOnlyProject]));
57
+ write(userDoc, `${OLD_DOC}\nSee [[user/scoped]].\n`);
58
+ write(join(userRoot, 'memory', 'scoped', 'INDEX.md'), CURRENT_DOC);
59
+ write(rootDoc, `${OLD_DOC}\nSee [[shared]].\n`);
60
+ write(appletDoc, `${OLD_DOC}\nSee [[shared]].\n`);
61
+ write(join(appletProfile, 'memory', 'shared', 'INDEX.md'), CURRENT_DOC);
62
+ write(projectDoc, OLD_DOC);
63
+ write(join(appletProject, '.crouter', 'memory', 'scoped', 'INDEX.md'), CURRENT_DOC);
64
+ write(deepDoc, OLD_DOC);
65
+ write(join(pluginRoot, '.crouter-plugin', 'plugin.json'), JSON.stringify({ name: 'deep-memory', version: '1.0.0' }));
66
+ write(pluginDoc, OLD_DOC);
67
+ write(join(embeddedPluginRoot, '.crouter-plugin', 'plugin.json'), JSON.stringify({ name: 'embedded-memory', version: '1.0.0' }));
68
+ write(join(nestedOnlyProject, 'embedded', '.git', 'HEAD'), 'ref: refs/heads/main\n');
69
+ write(embeddedPluginDoc, OLD_DOC);
70
+ const env = {
71
+ ...process.env,
72
+ HOME: home,
73
+ CRTR_PROFILE_ID: 'root-00000000',
74
+ CRTR_NO_AUTO_INIT: '1',
75
+ CRTR_NO_AUTO_UPDATE: '1',
76
+ };
77
+ delete env.CRTR_NODE_ID;
78
+ const result = spawnSync(process.execPath, ['--conditions=crtr-src', '--import', TSX_LOADER, CLI, 'sys', 'migrate', '--json'], { cwd: home, env, encoding: 'utf8' });
79
+ assert.equal(result.status, 0, result.stderr);
80
+ const output = JSON.parse(result.stdout);
81
+ assert.equal(output.stores, 7);
82
+ assert.equal(output.changed, 7);
83
+ for (const path of [userDoc, rootDoc, appletDoc, projectDoc, deepDoc, pluginDoc, embeddedPluginDoc]) {
84
+ const migrated = readFileSync(path, 'utf8');
85
+ assert.doesNotMatch(migrated, /system-prompt-visibility|file-read-visibility/);
86
+ assert.match(migrated, /surfaces:\n - on: boot\n at: content/);
87
+ }
88
+ assert.match(readFileSync(userDoc, 'utf8'), /\[\[user\/scoped\/INDEX\]\]/);
89
+ assert.match(readFileSync(rootDoc, 'utf8'), /\[\[shared\]\]/);
90
+ assert.match(readFileSync(appletDoc, 'utf8'), /\[\[shared\/INDEX\]\]/);
91
+ }
92
+ finally {
93
+ rmSync(home, { recursive: true, force: true });
94
+ }
95
+ });
@@ -2,55 +2,83 @@
2
2
  // every writable memory store's documents to the current document format.
3
3
  // (Journaled canvas-db migrations run in the db's own ordered chain when the
4
4
  // daemon opens it; this leaf owns only the on-disk document stores.)
5
- import { join } from 'node:path';
5
+ import { dirname, join } from 'node:path';
6
6
  import { defineLeaf } from '../../core/command.js';
7
7
  import { pathExists, realpathOrSelf } from '../../core/fs-utils.js';
8
+ import { createMemoryDocSnapshot } from '../../core/memory-resolver.js';
8
9
  import { listInstalledPlugins, listInstalledPluginsInRoot } from '../../core/resolver.js';
9
10
  import { pluginMemoryDir, projectScopeRoots, scopeMemoryDir } from '../../core/scope.js';
10
- import { loadProfileManifest, profileMemoryDir } from '../../core/profiles/manifest.js';
11
- import { getDefaultProfileId } from '../../core/profiles/default-binding.js';
12
- import { descendantStoreRoots } from '../../core/nested-stores.js';
11
+ import { listProfiles, profileMemoryDir } from '../../core/profiles/manifest.js';
12
+ import { descendantStoreRootsExhaustive } from '../../core/nested-stores.js';
13
13
  import { memoryDir as nodeMemoryDir } from '../../core/runtime/memory.js';
14
14
  import { runConvergentMigrations } from '../../migrations/convergent.js';
15
+ function targetKey(target) {
16
+ return `${realpathOrSelf(target.cwd)}\u0000${target.profileId ?? ''}\u0000${target.nodeId ?? ''}`;
17
+ }
15
18
  /** Every writable memory store — the lint corpus minus builtin (package-owned,
16
- * regenerated on every install, so migrating it would fight the package). */
17
- function migratableStoreDirs() {
18
- const dirs = new Set();
19
- const add = (dir) => {
20
- // realpath so one store reached through two spellings (e.g. a symlinked
21
- // ancestor) migrates once.
22
- if (dir !== null && dir !== '' && pathExists(dir))
23
- dirs.add(realpathOrSelf(dir));
24
- };
25
- const projectRoots = projectScopeRoots();
26
- for (const root of projectRoots) {
27
- add(join(root, 'memory'));
28
- for (const plugin of listInstalledPluginsInRoot('project', root)) {
29
- if (plugin.enabled)
30
- add(pluginMemoryDir(plugin));
19
+ * regenerated on every install, so migrating it would fight the package).
20
+ * Physical stores dedupe once while retaining every owning target corpus. */
21
+ function migratableStores() {
22
+ const stores = new Map();
23
+ const add = (dir, target) => {
24
+ if (dir === null || dir === '' || !pathExists(dir))
25
+ return;
26
+ const real = realpathOrSelf(dir);
27
+ let store = stores.get(real);
28
+ if (store === undefined) {
29
+ store = { dir: real, targets: [] };
30
+ stores.set(real, store);
31
31
  }
32
+ const key = targetKey(target);
33
+ if (!store.targets.some((candidate) => targetKey(candidate) === key))
34
+ store.targets.push(target);
35
+ };
36
+ const addProjectRoots = (roots, profileId) => {
37
+ const addRoot = (projectRoot) => {
38
+ const target = { cwd: dirname(projectRoot), profileId, nodeId: null };
39
+ add(join(projectRoot, 'memory'), target);
40
+ for (const plugin of listInstalledPluginsInRoot('project', projectRoot)) {
41
+ if (plugin.enabled)
42
+ add(pluginMemoryDir(plugin), target);
43
+ }
44
+ };
45
+ for (const root of roots)
46
+ addRoot(root);
47
+ for (const descendant of descendantStoreRootsExhaustive(roots))
48
+ addRoot(descendant);
49
+ };
50
+ const selectedProfileId = process.env['CRTR_PROFILE_ID'] || null;
51
+ addProjectRoots(projectScopeRoots(process.cwd(), null), selectedProfileId);
52
+ const profiles = listProfiles();
53
+ const profileTargets = [];
54
+ for (const { profileId, manifest } of profiles) {
55
+ const profileTarget = {
56
+ cwd: manifest.home ?? manifest.projects[0] ?? process.cwd(),
57
+ profileId,
58
+ nodeId: null,
59
+ };
60
+ profileTargets.push(profileTarget);
61
+ add(profileMemoryDir(profileId), profileTarget);
62
+ const roots = [
63
+ ...new Set(manifest.projects.flatMap((project) => [join(project, '.crouter'), ...projectScopeRoots(project, null)])),
64
+ ];
65
+ addProjectRoots(roots, profileId);
32
66
  }
33
- for (const root of descendantStoreRoots(projectRoots))
34
- add(join(root, 'memory'));
35
- add(scopeMemoryDir('user'));
67
+ const ambientTarget = { cwd: process.cwd(), profileId: selectedProfileId, nodeId: null };
68
+ const globalTargets = [ambientTarget, ...profileTargets];
69
+ const userDir = scopeMemoryDir('user');
70
+ for (const target of globalTargets)
71
+ add(userDir, target);
36
72
  for (const plugin of listInstalledPlugins('user')) {
37
- if (plugin.enabled)
38
- add(pluginMemoryDir(plugin));
39
- }
40
- const profileIdOrName = process.env['CRTR_PROFILE_ID'] || getDefaultProfileId(process.cwd());
41
- if (profileIdOrName) {
42
- try {
43
- const { profileId } = loadProfileManifest(profileIdOrName);
44
- add(profileMemoryDir(profileId));
45
- }
46
- catch {
47
- // Unresolvable profile: no profile store to migrate.
48
- }
73
+ if (!plugin.enabled)
74
+ continue;
75
+ for (const target of globalTargets)
76
+ add(pluginMemoryDir(plugin), target);
49
77
  }
50
78
  const nodeId = process.env['CRTR_NODE_ID'] ?? '';
51
79
  if (nodeId !== '')
52
- add(nodeMemoryDir(nodeId));
53
- return [...dirs];
80
+ add(nodeMemoryDir(nodeId), { cwd: process.cwd(), profileId: selectedProfileId, nodeId });
81
+ return [...stores.values()];
54
82
  }
55
83
  export const sysMigrateLeaf = defineLeaf({
56
84
  name: 'migrate',
@@ -71,7 +99,7 @@ export const sysMigrateLeaf = defineLeaf({
71
99
  ],
72
100
  outputKind: 'object',
73
101
  effects: [
74
- 'Rewrites old-format memory documents in place (atomic per-file writes) across the writable stores: project (plus nested descendant stores and installed plugin stores), user, profile, and this node\'s store. Builtin docs are package-owned and never touched.',
102
+ '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.',
75
103
  'Under --dry-run: read-only, writes nothing.',
76
104
  ],
77
105
  },
@@ -79,13 +107,39 @@ export const sysMigrateLeaf = defineLeaf({
79
107
  const dryRun = input.dryRun === true;
80
108
  const applied = [];
81
109
  const skipped = [];
82
- const stores = migratableStoreDirs();
110
+ const stores = migratableStores();
111
+ const snapshots = new Map();
83
112
  for (const store of stores) {
84
- const result = runConvergentMigrations(store, { dryRun });
113
+ const resolvers = store.targets.map((target) => (name) => {
114
+ const key = targetKey(target);
115
+ let snapshot = snapshots.get(key);
116
+ if (snapshot === undefined) {
117
+ snapshot = createMemoryDocSnapshot(target);
118
+ snapshots.set(key, snapshot);
119
+ }
120
+ return snapshot.legacyDirectoryIndex(name);
121
+ });
122
+ const result = runConvergentMigrations(store.dir, {
123
+ dryRun,
124
+ context: {
125
+ legacyDirectoryIndex: (name) => {
126
+ let replacement = null;
127
+ for (const resolve of resolvers) {
128
+ const candidate = resolve(name);
129
+ if (candidate === null)
130
+ return null;
131
+ if (replacement !== null && replacement !== candidate)
132
+ return null;
133
+ replacement = candidate;
134
+ }
135
+ return replacement;
136
+ },
137
+ },
138
+ });
85
139
  for (const row of result.applied)
86
- applied.push({ store, migration: row.migration, files: row.files });
140
+ applied.push({ store: store.dir, migration: row.migration, files: row.files });
87
141
  for (const s of result.skipped)
88
- skipped.push({ store, relPath: s.relPath, error: s.error });
142
+ skipped.push({ store: store.dir, relPath: s.relPath, error: s.error });
89
143
  }
90
144
  const changed = applied.reduce((n, row) => n + row.files.length, 0);
91
145
  return {
@@ -132,8 +132,12 @@ export interface MemoryDocSnapshot {
132
132
  /** Resolve names against that source snapshot; failures are omitted so
133
133
  * callers can retain their own per-document error behavior. */
134
134
  resolve(names: readonly string[]): Map<string, MemoryDoc>;
135
+ /** Frozen pre-cut resolution used only by the surfaces migration: return the
136
+ * explicit INDEX spelling when a bare directory/plugin ref and that spelling
137
+ * resolve to the same file in this target corpus. */
138
+ legacyDirectoryIndex(name: string): string | null;
135
139
  }
136
- export declare function createMemoryDocSnapshot(): MemoryDocSnapshot;
140
+ export declare function createMemoryDocSnapshot(target?: MemoryTarget): MemoryDocSnapshot;
137
141
  /** Resolve several unqualified names over one memory-source snapshot. Failed
138
142
  * names are omitted so callers can preserve their per-document error behavior. */
139
143
  export declare function resolveMemoryDocs(names: readonly string[]): Map<string, MemoryDoc>;
@@ -1,4 +1,4 @@
1
- import { join, relative, sep } from 'node:path';
1
+ import { basename, join, relative, sep } from 'node:path';
2
2
  import { readdirSync } from 'node:fs';
3
3
  import { CRTR_DIR_NAME } from '../types.js';
4
4
  import { pathExists, readText, walkFiles } from './fs-utils.js';
@@ -343,32 +343,47 @@ export function resolveHistoryLogPath(historyDir, segments) {
343
343
  * never authored with a numeric prefix); resolution against the physical tree
344
344
  * is prefix-blind via `resolveNormalizedPath` so a normalized name finds an
345
345
  * `NN-`-pinned physical file/dir. */
346
- function findMemoryMatchInSource(name, segments, source) {
346
+ function findMemoryMatchInSource(name, segments, source, legacyDirectoryIndex = false) {
347
347
  const isLegacySkillDoc = segments.at(-1) === 'SKILL';
348
348
  // Native memory dir first inside this source (native-before-plugin
349
349
  // precedence), then its enabled plugins.
350
350
  const dir = source.memoryDir;
351
351
  if (dir) {
352
- const { filePath } = resolveNormalizedPath(dir, segments);
352
+ const { filePath, dirPath } = resolveNormalizedPath(dir, segments);
353
353
  if (!isLegacySkillDoc && filePath !== null)
354
354
  return loadMemoryDoc(source.scope, dir, filePath, name);
355
+ if (legacyDirectoryIndex && dirPath !== null) {
356
+ const indexPath = join(dirPath, 'INDEX.md');
357
+ if (pathExists(indexPath))
358
+ return loadMemoryDoc(source.scope, dir, indexPath, name);
359
+ }
355
360
  }
356
361
  // Plugin memory dir: a `<plugin>/<rest>` name resolves against that enabled
357
362
  // plugin's memory/ tree (the `<pluginName>/` mount that listAllMemoryDocs
358
363
  // enumerates — `read` must resolve what `list` shows).
359
364
  const slash = name.indexOf('/');
360
- if (slash <= 0)
365
+ if (slash <= 0 && !legacyDirectoryIndex)
361
366
  return undefined;
362
- const pluginName = name.slice(0, slash);
363
- const rest = name.slice(slash + 1);
367
+ const pluginName = slash > 0 ? name.slice(0, slash) : name;
368
+ const rest = slash > 0 ? name.slice(slash + 1) : '';
364
369
  for (const p of source.plugins) {
365
370
  if (p.name !== pluginName)
366
371
  continue;
367
372
  const pdir = pluginMemoryDir(p);
368
- const restSegments = rest.split('/');
369
- const { filePath } = resolveNormalizedPath(pdir, restSegments);
370
- if (restSegments.at(-1) !== 'SKILL' && filePath !== null)
371
- return loadMemoryDoc(source.scope, pdir, filePath, name);
373
+ const restSegments = rest === '' ? [] : rest.split('/');
374
+ if (restSegments.length > 0) {
375
+ const { filePath } = resolveNormalizedPath(pdir, restSegments);
376
+ if (restSegments.at(-1) !== 'SKILL' && filePath !== null)
377
+ return loadMemoryDoc(source.scope, pdir, filePath, name);
378
+ }
379
+ if (legacyDirectoryIndex) {
380
+ const indexDir = rest === '' ? pdir : resolveNormalizedPath(pdir, restSegments).dirPath;
381
+ if (indexDir !== null) {
382
+ const indexPath = join(indexDir, 'INDEX.md');
383
+ if (pathExists(indexPath))
384
+ return loadMemoryDoc(source.scope, pdir, indexPath, name);
385
+ }
386
+ }
372
387
  }
373
388
  return undefined;
374
389
  }
@@ -384,8 +399,7 @@ function loadMemorySources(target, scope, includeDescendants = false) {
384
399
  docs: sourceMemoryDocs(source, true),
385
400
  }));
386
401
  }
387
- export function createMemoryDocSnapshot() {
388
- const target = ambientTarget();
402
+ export function createMemoryDocSnapshot(target = ambientTarget()) {
389
403
  const defaultSources = loadMemorySources(target);
390
404
  const sourcesByScope = new Map([[undefined, defaultSources]]);
391
405
  const sourceFor = (scope) => {
@@ -412,9 +426,26 @@ export function createMemoryDocSnapshot() {
412
426
  }
413
427
  return resolved;
414
428
  },
429
+ legacyDirectoryIndex: (name) => {
430
+ if (name === 'INDEX' || name.endsWith('/INDEX'))
431
+ return null;
432
+ try {
433
+ const parsed = parseSkillQualifier(name);
434
+ const sources = sourceFor(parsed.scope);
435
+ const legacy = resolveMemoryDocFromSources(name, {}, sources, true);
436
+ if (basename(legacy.path) !== 'INDEX.md')
437
+ return null;
438
+ const explicitName = `${name}/INDEX`;
439
+ const explicit = resolveMemoryDocFromSources(explicitName, {}, sources);
440
+ return explicit.path === legacy.path ? explicitName : null;
441
+ }
442
+ catch {
443
+ return null;
444
+ }
445
+ },
415
446
  };
416
447
  }
417
- function resolveMemoryDocFromSources(rawName, opts, sources) {
448
+ function resolveMemoryDocFromSources(rawName, opts, sources, legacyDirectoryIndex = false) {
418
449
  const parsed = parseSkillQualifier(rawName);
419
450
  if (parsed.scope && opts.scope && parsed.scope !== opts.scope) {
420
451
  throw usage(`scope conflict: identifier "${rawName}" uses scope "${parsed.scope}" but --scope is "${opts.scope}"`);
@@ -433,7 +464,7 @@ function resolveMemoryDocFromSources(rawName, opts, sources) {
433
464
  const identity = docs.find((d) => d.name === name);
434
465
  if (identity && kindOk(identity))
435
466
  return identity;
436
- const direct = findMemoryMatchInSource(name, segments, source);
467
+ const direct = findMemoryMatchInSource(name, segments, source, legacyDirectoryIndex);
437
468
  if (direct && kindOk(direct))
438
469
  return direct;
439
470
  }
@@ -10,3 +10,8 @@
10
10
  * duplicated dedupe logic.
11
11
  */
12
12
  export declare function descendantStoreRoots(ancestorRoots: string[]): string[];
13
+ /** Complete descendant-store discovery for state migrations. Unlike the
14
+ * interactive addressing lane, this filesystem walk has no time or depth
15
+ * budget, crosses embedded repositories, admits plugin-only `.crouter` roots,
16
+ * and does not cache. */
17
+ export declare function descendantStoreRootsExhaustive(ancestorRoots: string[]): string[];
@@ -113,20 +113,24 @@ function collectGitStores(owner, add, timeoutMs, trip) {
113
113
  /** Non-git owner: DFS with junk skips, a depth cap, and a stop at any embedded
114
114
  * `.git` dir (an embedded repo is its own world — register it explicitly). */
115
115
  function collectWalkStores(owner, add, deadline, trip) {
116
+ collectWalkStoresFromOwner(owner, add, { deadline, maxDepth: MAX_DEPTH, trip });
117
+ }
118
+ function collectWalkStoresFromOwner(owner, add, opts = {}) {
116
119
  const stack = [{ dir: owner, depth: 0 }];
117
120
  while (stack.length > 0) {
118
- if (Date.now() > deadline) {
119
- trip();
121
+ if (opts.deadline !== undefined && Date.now() > opts.deadline) {
122
+ opts.trip?.();
120
123
  return;
121
124
  }
122
125
  const { dir, depth } = stack.pop();
123
126
  if (dir !== owner) {
124
- if (pathExists(join(dir, '.git')))
127
+ if (opts.crossEmbeddedRepos !== true && pathExists(join(dir, '.git')))
125
128
  continue;
126
- if (pathExists(join(dir, CRTR_DIR_NAME, 'memory')))
127
- add(join(dir, CRTR_DIR_NAME));
129
+ const storeRoot = join(dir, CRTR_DIR_NAME);
130
+ if (pathExists(opts.includePluginOnly === true ? storeRoot : join(storeRoot, 'memory')))
131
+ add(storeRoot);
128
132
  }
129
- if (depth >= MAX_DEPTH)
133
+ if (opts.maxDepth !== undefined && depth >= opts.maxDepth)
130
134
  continue;
131
135
  let entries;
132
136
  try {
@@ -144,3 +148,24 @@ function collectWalkStores(owner, add, deadline, trip) {
144
148
  }
145
149
  }
146
150
  }
151
+ /** Complete descendant-store discovery for state migrations. Unlike the
152
+ * interactive addressing lane, this filesystem walk has no time or depth
153
+ * budget, crosses embedded repositories, admits plugin-only `.crouter` roots,
154
+ * and does not cache. */
155
+ export function descendantStoreRootsExhaustive(ancestorRoots) {
156
+ const ancestorReal = new Set(ancestorRoots.map((root) => realpathOrSelf(root)));
157
+ const found = [];
158
+ const seen = new Set();
159
+ const add = (storeRoot) => {
160
+ const real = realpathOrSelf(storeRoot);
161
+ if (seen.has(real) || ancestorReal.has(real))
162
+ return;
163
+ seen.add(real);
164
+ found.push(storeRoot);
165
+ };
166
+ const owners = new Set(ancestorRoots.map((root) => realpathOrSelf(dirname(root))));
167
+ for (const owner of owners) {
168
+ collectWalkStoresFromOwner(owner, add, { includePluginOnly: true, crossEmbeddedRepos: true });
169
+ }
170
+ return found;
171
+ }
@@ -37,7 +37,7 @@ import { gatePasses } from './gate.js';
37
37
  import { commandDeliveryRung, memoryReadDeliveryRung, owningRootOf, readDeliveryRung, workspaceOpenRung } from './surface-match.js';
38
38
  import { deliveredAtOrAbove, recordDelivery } from './injected-store.js';
39
39
  import { normalizeDocName, parseSubstrateDoc, parseSubstrateFrontmatter, previewLine, resolveDocName } from './schema.js';
40
- import { cachedSubstrateDocs } from './session-cache.js';
40
+ import { cachedSubstrateDocsInclusive } from './session-cache.js';
41
41
  import { realpathOrSelf } from '../fs-utils.js';
42
42
  const JUNK_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.cache', '.yalc']);
43
43
  function attr(s) {
@@ -137,9 +137,14 @@ function enclosingProjectDocs(absReadFile) {
137
137
  }
138
138
  return docs;
139
139
  }
140
+ // The event corpus INCLUDES node-scope docs: surfaces entries are explicit
141
+ // authored routing, so a node-local doc's read/memory-read/command entry
142
+ // fires like any other store's. Only boot excludes node scope (render.ts's
143
+ // nodeLocalDocs owns node-local boot rendering); the shared dedup set keeps
144
+ // a boot-rendered doc from re-delivering below its rung here.
140
145
  function resolvedDocs() {
141
146
  try {
142
- return cachedSubstrateDocs(listAllMemoryDocs, parseSubstrateDoc);
147
+ return cachedSubstrateDocsInclusive(listAllMemoryDocs, parseSubstrateDoc);
143
148
  }
144
149
  catch {
145
150
  return [];
@@ -27,12 +27,13 @@
27
27
  // doc (e.g. the inline memory-reference inventory) use
28
28
  // this pair.
29
29
  // • allMemoryDocs — allMemoryDocsInclusive with node-scope docs excluded
30
- // (unchanged boot/on-read behavior — see the comment on
31
- // cachedAllMemoryDocs). Shared by both render.ts and
32
- // on-read.ts.
30
+ // (boot-only view — see the comment on
31
+ // cachedAllMemoryDocs). Consumed by render.ts.
33
32
  // • substrateDocs — allMemoryDocs mapped through parseSubstrateDoc and
34
- // null-filtered. Used by render.ts's resolverDocs and
35
- // on-read.ts's appliesToCandidates.
33
+ // null-filtered. Used by render.ts's resolver corpus.
34
+ // The event corpus (on-read.ts) uses the INCLUSIVE
35
+ // pair instead: node docs' surfaces entries fire on
36
+ // read/memory-read/command like any other store's.
36
37
  // Node subjects are deliberately NOT cached: lifecycle, mode, kind, and other
37
38
  // gate inputs can change while a resident broker/session remains live. Rendering
38
39
  // must always gate against the current persisted node metadata.
@@ -77,14 +78,15 @@ export function cachedSubstrateDocsInclusive(listFn, parseFn) {
77
78
  * `listFn` is injected by the caller to avoid circular imports at module init. */
78
79
  export function cachedAllMemoryDocs(listFn) {
79
80
  if (_cache.allMemoryDocs === null) {
80
- // The boot pipeline (render + on-read) EXCLUDES node-scope docs: node-local
81
+ // The BOOT pipeline (render.ts) EXCLUDES node-scope docs: node-local
81
82
  // memory surfaces at boot via render.ts's nodeLocalDocs (any kind, floored
82
83
  // into the knowledge block), NOT through the resolver corpus, so a node doc
83
- // must not also enter here (it would double-surface at boot and could fire
84
- // on-read). The `node` scope reaches CLI read/list/find/delete through the
85
- // uncached listAllMemoryDocs() path instead. Derived from the inclusive
86
- // cache by filtering, so listFn() itself still runs at most once per
87
- // session even though both views are consumed.
84
+ // must not also enter there (it would double-surface at boot). The event
85
+ // corpus (on-read.ts) uses the inclusive view — node docs' surfaces
86
+ // entries fire on read/memory-read/command. The `node` scope reaches CLI
87
+ // read/list/find/delete through the uncached listAllMemoryDocs() path.
88
+ // Derived from the inclusive cache by filtering, so listFn() itself still
89
+ // runs at most once per session even though both views are consumed.
88
90
  _cache.allMemoryDocs = cachedAllMemoryDocsInclusive(listFn).filter((d) => d.scope !== 'node');
89
91
  }
90
92
  return _cache.allMemoryDocs;
@@ -18,11 +18,9 @@
18
18
  // folded scalars). Each splice is validated by re-parsing: the result must
19
19
  // equal the original record minus the dead fields plus `surfaces`, or the
20
20
  // migration throws naming the doc.
21
- import { basename } from 'node:path';
22
21
  import { isMap, parse as parseYaml, parseDocument, stringify } from 'yaml';
23
22
  import { parseFrontmatterGeneric } from '../core/frontmatter.js';
24
23
  import { findDocLinks } from '../core/memory/doc-link-grammar.js';
25
- import { createMemoryDocSnapshot } from '../core/memory-resolver.js';
26
24
  const OLD_FIELDS = ['system-prompt-visibility', 'file-read-visibility', 'applies-to', 'read-when'];
27
25
  // ---------------------------------------------------------------------------
28
26
  // Frozen old-schema parsing (pre-cut substrate/schema.ts semantics).
@@ -136,42 +134,22 @@ function planEntries(d, bootRung) {
136
134
  }
137
135
  // ---------------------------------------------------------------------------
138
136
  // Rule 5: bare-dir `[[ref]]` → `[[dir/INDEX]]`. A ref is rewritten only when
139
- // it resolves through the bare-dir/bare-plugin-name → INDEX.md convenience
140
- // (resolved file is an INDEX.md the ref does not name) AND `<ref>/INDEX`
141
- // provably resolves to the same file — meaning-preserving or untouched.
142
- // Resolution uses the fs-only memory resolver from this process's ambient
143
- // target, because refs cross stores (user docs point into plugin dirs).
137
+ // the owning target corpus proves the old bare-directory convenience and the
138
+ // explicit INDEX spelling resolve to the same file. The decision cache belongs
139
+ // to one migration target; profile-local names never leak across stores.
144
140
  // ---------------------------------------------------------------------------
145
- let corpusMemo = null;
146
- const refDecisionMemo = new Map();
147
- function resolveOne(name) {
148
- corpusMemo ??= createMemoryDocSnapshot();
149
- return corpusMemo.resolve([name]).get(name);
150
- }
151
- function indexRefReplacement(name) {
152
- const memo = refDecisionMemo.get(name);
153
- if (memo !== undefined)
154
- return memo;
155
- let out = null;
156
- if (name !== 'INDEX' && !name.endsWith('/INDEX')) {
157
- const hit = resolveOne(name);
158
- if (hit !== undefined && basename(hit.path) === 'INDEX.md') {
159
- const explicit = resolveOne(`${name}/INDEX`);
160
- if (explicit !== undefined && explicit.path === hit.path)
161
- out = `${name}/INDEX`;
162
- }
163
- }
164
- refDecisionMemo.set(name, out);
165
- return out;
166
- }
167
- function rewriteBareDirRefs(body) {
141
+ function rewriteBareDirRefs(body, context, decisions) {
168
142
  const links = findDocLinks(body);
169
143
  if (links.length === 0)
170
144
  return body;
171
145
  let out = '';
172
146
  let pos = 0;
173
147
  for (const link of links) {
174
- const repl = indexRefReplacement(link.name);
148
+ let repl = decisions.get(link.name);
149
+ if (repl === undefined) {
150
+ repl = context?.legacyDirectoryIndex(link.name) ?? null;
151
+ decisions.set(link.name, repl);
152
+ }
175
153
  if (repl === null)
176
154
  continue;
177
155
  out += body.slice(pos, link.start) + `[[${repl}]]`;
@@ -258,12 +236,13 @@ function rebuildSource(d, newYaml, newBody) {
258
236
  export const surfacesFrontmatterMigration = {
259
237
  lane: 'convergent',
260
238
  description: 'surfaces frontmatter: fold visibility/applies-to/read-when routing into surfaces entries; rewrite bare-directory [[refs]] to [[dir/INDEX]]',
261
- apply(store) {
239
+ apply(store, context) {
262
240
  const docs = store.docs.map(classify).filter((d) => d !== null);
263
241
  const ceiling = buildCeiling(docs);
242
+ const decisions = new Map();
264
243
  const changes = [];
265
244
  for (const d of docs) {
266
- const newBody = rewriteBareDirRefs(d.doc.body);
245
+ const newBody = rewriteBareDirRefs(d.doc.body, context, decisions);
267
246
  if (!d.hasOldFields && newBody === d.doc.body)
268
247
  continue;
269
248
  const newYaml = d.hasOldFields
@@ -1,4 +1,4 @@
1
- import type { StateMigration, StoreSnapshot } from './types.js';
1
+ import type { ConvergentMigrationContext, StateMigration, StoreSnapshot } from './types.js';
2
2
  /** A doc excluded from the snapshot because its frontmatter is not valid YAML.
3
3
  * Per-doc throw isolation at the collection layer: the bad file is named and
4
4
  * the rest of the store still migrates. */
@@ -28,4 +28,5 @@ export declare function loadStoreSnapshot(root: string): {
28
28
  export declare function runConvergentMigrations(root: string, opts?: {
29
29
  dryRun?: boolean;
30
30
  migrations?: readonly StateMigration[];
31
+ context?: ConvergentMigrationContext;
31
32
  }): ConvergentRunResult;
@@ -57,7 +57,7 @@ export function runConvergentMigrations(root, opts = {}) {
57
57
  const applied = [];
58
58
  for (const migration of migrations) {
59
59
  const bySource = new Map(snapshot.docs.map((d) => [d.path, d.source]));
60
- const changes = migration.apply(snapshot).filter((c) => bySource.get(c.path) !== c.after);
60
+ const changes = migration.apply(snapshot, opts.context).filter((c) => bySource.get(c.path) !== c.after);
61
61
  if (changes.length === 0)
62
62
  continue;
63
63
  for (const change of changes) {
@@ -7,12 +7,17 @@ export interface JournaledMigration {
7
7
  * (a re-run after a partial application completes rather than corrupts). */
8
8
  apply(db: DatabaseSync, canvasHome: string): void;
9
9
  }
10
+ export interface ConvergentMigrationContext {
11
+ /** Frozen pre-cut directory-INDEX resolution for this store's owning memory
12
+ * corpus. Null means rewriting the bare ref would not preserve meaning. */
13
+ legacyDirectoryIndex(name: string): string | null;
14
+ }
10
15
  export interface ConvergentMigration {
11
16
  lane: 'convergent';
12
17
  description: string;
13
18
  /** Pure planning: return the full new text of each doc to rewrite. The
14
19
  * runner filters no-op changes and performs the writes. */
15
- apply(store: StoreSnapshot): DocChange[];
20
+ apply(store: StoreSnapshot, context?: ConvergentMigrationContext): DocChange[];
16
21
  }
17
22
  export type StateMigration = JournaledMigration | ConvergentMigration;
18
23
  /** One parsed markdown doc in a store snapshot. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.210",
3
+ "version": "0.3.212",
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.210",
3
+ "version": "0.3.212",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.210",
9
+ "version": "0.3.212",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {