@north-light/crouter 0.3.211 → 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
+ }
@@ -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.211",
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.211",
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.211",
9
+ "version": "0.3.212",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {