@lanes-sh/link 0.8.0 → 0.9.0

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 (87) hide show
  1. package/instructions/skills/lanes-link/SKILL.md +12 -12
  2. package/package.json +1 -1
  3. package/src/auth/oauth/store.ts +21 -5
  4. package/src/cli/accepts.ts +6 -5
  5. package/src/cli/commands/connect/index.ts +2 -1
  6. package/src/cli/commands/connect/settle.ts +12 -4
  7. package/src/cli/commands/connection.ts +2 -1
  8. package/src/cli/commands/identity.ts +4 -4
  9. package/src/cli/commands/knowledge/index.ts +9 -16
  10. package/src/cli/commands/knowledge/migrate.ts +9 -4
  11. package/src/cli/commands/knowledge/show.ts +14 -9
  12. package/src/cli/commands/operate/migrate.ts +5 -2
  13. package/src/cli/commands/operate/pair.ts +1 -1
  14. package/src/cli/commands/operate/serve.ts +1 -1
  15. package/src/cli/commands/owner/assets.ts +2 -2
  16. package/src/cli/commands/owner/entities.ts +2 -2
  17. package/src/cli/commands/owner/memory.ts +2 -2
  18. package/src/cli/commands/owner/tasks.ts +2 -2
  19. package/src/cli/commands/owner/vault.ts +3 -3
  20. package/src/cli/commands/profile/disposition.ts +236 -0
  21. package/src/cli/commands/profile/removal.ts +100 -13
  22. package/src/cli/commands/profile/remove.ts +67 -6
  23. package/src/cli/commands/profile.ts +33 -6
  24. package/src/cli/commands/secrets.ts +4 -4
  25. package/src/cli/commands/update-migration.ts +54 -0
  26. package/src/cli/commands/update.ts +36 -23
  27. package/src/cli/config-edit.ts +29 -8
  28. package/src/cli/config-repair-sweep.ts +119 -0
  29. package/src/cli/config-repair.ts +90 -141
  30. package/src/cli/config-templates.ts +26 -24
  31. package/src/cli/contract3-credentials.ts +294 -0
  32. package/src/cli/contract3-data.ts +143 -209
  33. package/src/cli/contract3-layout.ts +46 -0
  34. package/src/cli/contract3-shape.ts +34 -8
  35. package/src/cli/contract3.ts +141 -24
  36. package/src/cli/contract4-credentials.ts +207 -0
  37. package/src/cli/contract4-data.ts +399 -0
  38. package/src/cli/contract4-rename.ts +73 -0
  39. package/src/cli/contract4-yaml.ts +223 -0
  40. package/src/cli/contract4.ts +342 -0
  41. package/src/cli/identity.ts +44 -26
  42. package/src/cli/main.ts +6 -1
  43. package/src/cli/migrate-move.ts +166 -0
  44. package/src/cli/migrate-plan.ts +3 -3
  45. package/src/cli/publish.ts +1 -5
  46. package/src/cli/runtime/open.ts +5 -5
  47. package/src/cli/runtime/select.ts +2 -11
  48. package/src/cli/runtime/stores.ts +16 -11
  49. package/src/cli/runtime/vault.ts +2 -2
  50. package/src/cli/usage.ts +5 -1
  51. package/src/cli/workspace-migrate.ts +32 -11
  52. package/src/connectivity/manifest/provider.ts +31 -12
  53. package/src/connectivity/transports/imap/parser.ts +70 -9
  54. package/src/deployments/adapters/filesystem.ts +18 -3
  55. package/src/deployments/deploy.ts +5 -5
  56. package/src/deployments/gcp/bucket.ts +42 -6
  57. package/src/deployments/knowledge.ts +9 -4
  58. package/src/deployments/target.ts +28 -7
  59. package/src/deployments/upload.ts +39 -30
  60. package/src/profile/connections.ts +13 -1
  61. package/src/profile/deployments.ts +86 -8
  62. package/src/profile/index.ts +5 -1
  63. package/src/profile/knowledge.ts +18 -5
  64. package/src/profile/layout.ts +147 -71
  65. package/src/profile/load.ts +53 -17
  66. package/src/profile/schema.ts +11 -2
  67. package/src/profile/testing.ts +45 -10
  68. package/src/profile/workspace.ts +66 -30
  69. package/src/providers/assets/provider.ts +6 -6
  70. package/src/providers/entities/provider.ts +6 -6
  71. package/src/providers/entities/writes.ts +1 -1
  72. package/src/providers/identity/provider.ts +1 -1
  73. package/src/providers/memory/provider.ts +6 -6
  74. package/src/providers/setup/provider.ts +3 -3
  75. package/src/providers/skills/provider.ts +2 -2
  76. package/src/providers/tasks/provider.ts +6 -6
  77. package/src/providers/vault/provider.ts +1 -1
  78. package/src/registry/registry.ts +1 -1
  79. package/src/server/endpoint.ts +4 -0
  80. package/src/server/harness.ts +1 -1
  81. package/src/server/mcp/instructions.ts +21 -21
  82. package/src/server/mcp/routing.ts +3 -3
  83. package/src/server/mcp/tools.ts +16 -3
  84. package/src/server/mcp/visibility.ts +56 -4
  85. package/src/stores/blobs/conformance.ts +19 -0
  86. package/src/stores/state/index.ts +76 -10
  87. package/src/stores/state/testing.ts +5 -1
@@ -39,8 +39,12 @@ export interface LoadedConfig {
39
39
  * 3. Schema shape.
40
40
  * 4. Referential integrity, which needs a well-formed document to check.
41
41
  */
42
- export function validateConfig(raw: unknown, source = '<config>'): Config {
43
- const config = validateConfigShape(raw, source);
42
+ export function validateConfig(
43
+ raw: unknown,
44
+ source = '<config>',
45
+ contract = SUPPORTED_CONTRACT,
46
+ ): Config {
47
+ const config = validateConfigShape(raw, source, contract);
44
48
  assertReferentialIntegrity(config, source);
45
49
  return config;
46
50
  }
@@ -58,12 +62,16 @@ export function validateConfig(raw: unknown, source = '<config>'): Config {
58
62
  * through `validateConfig`, and the split exists so that the one command whose
59
63
  * job is to fix a refusal is not blocked by it.
60
64
  */
61
- export function validateConfigShape(raw: unknown, source = '<config>'): Config {
65
+ export function validateConfigShape(
66
+ raw: unknown,
67
+ source = '<config>',
68
+ contract = SUPPORTED_CONTRACT,
69
+ ): Config {
62
70
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
63
71
  throw new ConfigError(`${source}: expected a YAML mapping at the top level`);
64
72
  }
65
73
 
66
- assertSupportedContract(raw, source);
74
+ assertSupportedContract(raw, source, contract);
67
75
 
68
76
  const secrets = findSecrets(raw);
69
77
  if (secrets.length > 0) {
@@ -88,7 +96,17 @@ export function validateConfigShape(raw: unknown, source = '<config>'): Config {
88
96
  * risks reading a document as more permissive than the operator wrote it, and
89
97
  * refusing to start is always the safer failure.
90
98
  */
91
- function assertSupportedContract(raw: object, source: string): void {
99
+ /**
100
+ * The contract this document must declare.
101
+ *
102
+ * `expected` is `SUPPORTED_CONTRACT` everywhere except inside a migration,
103
+ * which writes intermediate shapes on the way to the newest one — contract 1 to
104
+ * 2 to 3 to 4, saving at each step. Refusing those would mean a migration
105
+ * cannot use `ConfigDocument` at all and has to write YAML past the validator,
106
+ * losing the secret-shaped-value check that is the reason the validator runs on
107
+ * every save. A step that declares which contract it is producing keeps both.
108
+ */
109
+ function assertSupportedContract(raw: object, source: string, expected: number): void {
92
110
  const contract = (raw as { contract?: unknown }).contract;
93
111
 
94
112
  if (typeof contract !== 'number' || !Number.isInteger(contract)) {
@@ -97,14 +115,20 @@ function assertSupportedContract(raw: object, source: string): void {
97
115
  );
98
116
  }
99
117
 
100
- if (contract !== SUPPORTED_CONTRACT) {
101
- const direction = contract > SUPPORTED_CONTRACT ? 'newer than' : 'older than';
118
+ if (contract !== expected) {
119
+ const direction = contract > expected ? 'newer than' : 'older than';
102
120
  throw new ConfigError(
103
121
  `${source}: contract ${contract} is ${direction} the contract this binary implements (${SUPPORTED_CONTRACT}). ` +
104
122
  `Refusing to load rather than guessing at what the document means. ` +
123
+ // **Naming the command is the whole point of this branch.** ADR-051
124
+ // records what a refusal that names none costs: `validateConfig` runs
125
+ // from every command, so one stale workspace takes `status`, `start`,
126
+ // `plan` and `doctor` down together, and "migrate the config" left an
127
+ // operator with a wall and no door. `doctor --fix` runs every migration
128
+ // between the contract on disk and this one.
105
129
  (contract > SUPPORTED_CONTRACT
106
130
  ? 'Upgrade lanes-link.'
107
- : 'Migrate the config, or use a matching lanes-link version.'),
131
+ : 'Migrate it with: lanes link doctor --fix'),
108
132
  );
109
133
  }
110
134
  }
@@ -166,8 +190,11 @@ function formatZodIssues(error: z.ZodError): string {
166
190
  export interface ProviderRename {
167
191
  /** What a row naming the old id should say instead. */
168
192
  readonly to: string;
169
- /** The account label that means this row is the built-in, not a vendor one. */
170
- readonly keeps: string;
193
+ /**
194
+ * The account label that meant this row was the built-in rather than a
195
+ * vendor's, or `null` where the id belongs to nobody now.
196
+ */
197
+ readonly keeps: string | null;
171
198
  /** What the plain noun now names, for the sentence below. */
172
199
  readonly becomes: string;
173
200
  /** What it used to name. */
@@ -190,8 +217,12 @@ export interface ProviderRename {
190
217
  export const RENAMED_PROVIDERS: Readonly<Record<string, ProviderRename>> = {
191
218
  tasks: {
192
219
  to: 'google_tasks',
193
- keeps: 'Tasks',
194
- becomes: 'the built-in task list',
220
+ // **`null`, where this was `'Tasks'`.** The refusal existed because the
221
+ // built-in claimed `tasks`, so a stale Google Tasks row rebound to it and
222
+ // the label was the only evidence of which was meant (ADR-051). The
223
+ // built-in is `lanes_tasks` now, so no label makes a row here legitimate.
224
+ keeps: null,
225
+ becomes: 'nobody\u2019s provider id \u2014 the built-in task list is lanes_tasks',
195
226
  was: 'Google Tasks',
196
227
  noun: 'task list',
197
228
  },
@@ -217,8 +248,9 @@ export function renamedProviderFor(connection: {
217
248
  account: string;
218
249
  }): ProviderRename | null {
219
250
  const moved = RENAMED_PROVIDERS[connection.provider];
220
- if (!moved || connection.account === moved.keeps) return null;
221
- return moved;
251
+ if (!moved) return null;
252
+ // `keeps === null` means the id belongs to nobody, so no label exempts a row.
253
+ return moved.keeps !== null && connection.account === moved.keeps ? null : moved;
222
254
  }
223
255
 
224
256
  export function describeRename(
@@ -229,11 +261,15 @@ export function describeRename(
229
261
  if (!moved) return null;
230
262
 
231
263
  return (
232
- `"${connection.provider}" is now ${moved.becomes}, and this row is labelled ` +
233
- `"${connection.account}" rather than "${moved.keeps}".\n` +
264
+ (moved.keeps === null
265
+ ? `"${connection.provider}" is ${moved.becomes}.\n`
266
+ : `"${connection.provider}" is now ${moved.becomes}, and this row is labelled ` +
267
+ `"${connection.account}" rather than "${moved.keeps}".\n`) +
234
268
  ` If it was ${moved.was}: set provider to ${moved.to} here, and rename any ` +
235
269
  `"${connection.provider}.*" policy rule.\n` +
236
- ` If it is your own ${moved.noun}: set account to ${moved.keeps}.\n` +
270
+ (moved.keeps === null
271
+ ? ` If it is your own ${moved.noun}: it is lanes_${connection.provider} now.\n`
272
+ : ` If it is your own ${moved.noun}: set account to ${moved.keeps}.\n`) +
237
273
  ` ${repair} applies the first, where a stored credential proves it.`
238
274
  );
239
275
  }
@@ -50,11 +50,20 @@ import { knowledgeTargetSchema } from './knowledge.ts';
50
50
  * `members:`, because a profile worth sharing needs to say who may consume it
51
51
  * (ADR-060).
52
52
  *
53
+ * **4 gave a profile its bytes back, and retired `data/`** (ADR-066, ADR-067).
54
+ * Contract 3 moved the owner layer's stores beside the *connection*, so a
55
+ * profile owned nothing and two profiles granting one memory read one note. The
56
+ * profile is the container again — `profiles/<name>/` holds its declaration and
57
+ * everything it owns — and `data/`, which had been the line between what a
58
+ * revision reads and what it writes, stopped drawing that line the moment the
59
+ * declaration moved inside it. No file's *shape* changed: `grants:`,
60
+ * `members:` and `connections.yaml` are untouched, and only the paths moved.
61
+ *
53
62
  * A hard cut each time, and only the newest is read here. `./legacy.ts`
54
63
  * understands the older shapes and only the migration uses it — a runtime that
55
64
  * loaded either would be the two-sources-of-truth problem again, one level up.
56
65
  */
57
- export const SUPPORTED_CONTRACT = 3;
66
+ export const SUPPORTED_CONTRACT = 4;
58
67
 
59
68
  /**
60
69
  * There is no `database:` block any more.
@@ -363,7 +372,7 @@ export const policySchema = z.object({
363
372
  * So the refusal is at load, where it names both rows, rather than at call time
364
373
  * where one would silently win.
365
374
  */
366
- export const SINGLE_INSTANCE_PROVIDERS: readonly string[] = ['skills', 'vault'];
375
+ export const SINGLE_INSTANCE_PROVIDERS: readonly string[] = ['lanes_skills', 'lanes_vault'];
367
376
 
368
377
  /**
369
378
  * One connection, and what may be done with it (ADR-058).
@@ -89,13 +89,13 @@ export function connectionsYaml(
89
89
  extra: readonly { id: string; provider: string; account: string }[] = [],
90
90
  ): string {
91
91
  const owner = [
92
- { id: 'main', provider: 'memory', account: 'Memory' },
93
- { id: 'main', provider: 'tasks', account: 'Tasks' },
94
- { id: 'main', provider: 'assets', account: 'Assets' },
95
- { id: 'main', provider: 'skills', account: 'Skills' },
96
- { id: 'main', provider: 'vault', account: 'Vault' },
97
- { id: 'main', provider: 'setup', account: 'Setup' },
98
- { id: 'main', provider: 'entities', account: 'Entities' },
92
+ { id: 'lan1', provider: 'lanes_memory', account: 'Memory' },
93
+ { id: 'lan2', provider: 'lanes_tasks', account: 'Tasks' },
94
+ { id: 'lan3', provider: 'lanes_assets', account: 'Assets' },
95
+ { id: 'lan4', provider: 'lanes_skills', account: 'Skills' },
96
+ { id: 'lan5', provider: 'lanes_vault', account: 'Vault' },
97
+ { id: 'lan6', provider: 'lanes_setup', account: 'Setup' },
98
+ { id: 'lan7', provider: 'lanes_entities', account: 'Entities' },
99
99
  ];
100
100
 
101
101
  const rows = [...owner, ...extra]
@@ -109,9 +109,19 @@ export function connectionsYaml(
109
109
  export function grantsYaml(
110
110
  extra: readonly { connection: string; allow?: readonly string[]; deny?: readonly string[] }[] = [],
111
111
  ): string {
112
- const owner = ['memory', 'tasks', 'assets', 'skills', 'vault', 'setup', 'entities'].map(
113
- (provider) => ({ connection: `${provider}.main`, allow: [`${provider}.*`], deny: [] }),
114
- );
112
+ const owner = [
113
+ 'lanes_memory',
114
+ 'lanes_tasks',
115
+ 'lanes_assets',
116
+ 'lanes_skills',
117
+ 'lanes_vault',
118
+ 'lanes_setup',
119
+ 'lanes_entities',
120
+ ].map((provider, index) => ({
121
+ connection: `${provider}.lan${index + 1}`,
122
+ allow: [`${provider}.*`],
123
+ deny: [],
124
+ }));
115
125
 
116
126
  return [...owner, ...extra]
117
127
  .map(
@@ -143,3 +153,28 @@ export function profileYaml(
143
153
  `members:${members ? `\n${members}` : ' []'}\n`
144
154
  );
145
155
  }
156
+
157
+ /**
158
+ * Write a profile's declaration into a fixture workspace.
159
+ *
160
+ * A profile is a directory now (ADR-067), so writing one means creating that
161
+ * directory — which every fixture that used to write `profiles/<name>.yaml`
162
+ * beside its siblings got for free. Centralised here rather than repeated in
163
+ * twenty test files, for the reason `layout.ts` exists at all: the last time a
164
+ * path was spelled in two places one of them went stale, and the failure was a
165
+ * listing that disagreed with a loader about what existed.
166
+ */
167
+ export async function writeProfileFixture(
168
+ root: string,
169
+ profile: string,
170
+ body: string,
171
+ ): Promise<string> {
172
+ const { mkdir, writeFile } = await import('node:fs/promises');
173
+ const { dirname, join } = await import('node:path');
174
+ const { layout } = await import('./layout.ts');
175
+
176
+ const path = join(root, layout.profileConfig(profile));
177
+ await mkdir(dirname(path), { recursive: true });
178
+ await writeFile(path, body);
179
+ return path;
180
+ }
@@ -1,4 +1,5 @@
1
1
  import { existsSync } from 'node:fs';
2
+ import { layout, legacyProfileConfig, LEGACY_WORKSPACE_FILE, PROFILE_FILE } from './layout.ts';
2
3
  import { isRemoteWorkspace, readWorkspaceFile, workspaceFiles } from './files.ts';
3
4
  import { parseConfig, type LoadedConfig } from './load.ts';
4
5
  import { dirname, isAbsolute, join, resolve } from 'node:path';
@@ -19,13 +20,8 @@ import { findSecrets, formatSecretFindings } from './secret-detection.ts';
19
20
  /**
20
21
  * Workspace and profile resolution.
21
22
  *
22
- * A workspace is a directory holding one or more profiles:
23
- *
24
- * lanes-link.yaml workspace settings: contract, default_profile
25
- * profiles/
26
- * personal.yaml
27
- * work.yaml
28
- * data/ local state per profile, gitignored
23
+ * A workspace holds its registry, its accounts, and one directory per profile.
24
+ * `layout.ts` has the tree.
29
25
  *
30
26
  * **A command says which profile it means, or it does not run** (ADR-037).
31
27
  * `--profile` is the only thing that selects one. `LANES_LINK_PROFILE` and
@@ -33,23 +29,23 @@ import { findSecrets, formatSecretFindings } from './secret-detection.ts';
33
29
  *
34
30
  * The argument this replaces was that persisted selection is how operators act
35
31
  * on the wrong thing, and that a *visible* fallback — an exported variable, a
36
- * key in a file the operator reads, and a line printed before every command —
37
- * was therefore safe. The first half stands and is why this rule exists at all.
32
+ * key in a file the operator reads, a line printed before every command — was
33
+ * therefore safe. The first half stands and is why this rule exists at all.
38
34
  * What did not survive is the conclusion: the printed line is a dim grey one,
39
35
  * and a fallback made an ignored flag survivable, so `profile add --target
40
36
  * cloud` dropping its flag surfaced on the *next* command, from a different
41
- * source, detached from its cause. A resolver with nothing to fall back to
42
- * cannot do that.
37
+ * source, detached from its cause. A resolver with nothing to fall back to cannot.
43
38
  *
44
39
  * The workspace root is deliberately not part of this and keeps its chain —
45
- * `LANES_LINK_HOME`, then an ancestor holding `lanes-link.yaml`, then
46
- * `~/.lanes-link`. Getting it wrong yields "no profiles here" rather than an
40
+ * `LANES_LINK_HOME`, then an ancestor holding `workspaces.yaml` (or the
41
+ * `lanes-link.yaml` it was called before contract 4), then `~/.lanes-link`.
42
+ * Getting it wrong yields "no profiles here" rather than an
47
43
  * action against the wrong account, it is the only channel a container has for
48
44
  * its bucket (ADR-023), and the ancestor walk is what makes a per-repository
49
45
  * workspace work at all.
50
46
  */
51
47
 
52
- export const WORKSPACE_FILE = 'lanes-link.yaml';
48
+ export const WORKSPACE_FILE = 'workspaces.yaml';
53
49
 
54
50
  /** A profile, found. Everything a command needs before it has read the config. */
55
51
  export interface ProfileSelection {
@@ -104,7 +100,9 @@ export function resolveWorkspaceRoot(options: ResolveOptions = {}): string {
104
100
  // `existsSync`, not `Bun.file(path).size`: a missing file reports size 0,
105
101
  // so a `>= 0` check would call every candidate a workspace and stop at the
106
102
  // first directory it looked at.
107
- if (existsSync(join(directory, WORKSPACE_FILE))) return directory;
103
+ // Either name: a root that cannot be found cannot be migrated.
104
+ const marker = (name: string): boolean => existsSync(join(directory, name));
105
+ if (marker(WORKSPACE_FILE) || marker(LEGACY_WORKSPACE_FILE)) return directory;
108
106
  const parent = dirname(directory);
109
107
  if (parent === directory) break;
110
108
  directory = parent;
@@ -114,7 +112,7 @@ export function resolveWorkspaceRoot(options: ResolveOptions = {}): string {
114
112
  }
115
113
 
116
114
  /**
117
- * Where Lanes Link itself is installed — the directory holding `package.json`,
115
+ * Where Lanes Link itself is installed — the directory with `package.json`,
118
116
  * and with it `skills/` and `docs/`.
119
117
  *
120
118
  * Not the workspace: this is the code, not the operator's data. Found by
@@ -144,7 +142,7 @@ export function installRoot(from: string): string {
144
142
  * workspace stopped being a directory.
145
143
  */
146
144
  export function profilePath(workspaceRoot: string, profile: string): string {
147
- const key = `profiles/${profile}.yaml`;
145
+ const key = layout.profileConfig(profile);
148
146
  return isRemoteWorkspace(workspaceRoot) ? `${workspaceRoot}/${key}` : join(workspaceRoot, key);
149
147
  }
150
148
 
@@ -153,7 +151,7 @@ export async function loadProfileConfig(
153
151
  workspaceRoot: string,
154
152
  profile: string,
155
153
  ): Promise<LoadedConfig> {
156
- const key = `profiles/${profile}.yaml`;
154
+ const key = layout.profileConfig(profile);
157
155
  const text = await readWorkspaceFile(workspaceFiles(workspaceRoot), key);
158
156
  const shown = profilePath(workspaceRoot, profile);
159
157
 
@@ -208,9 +206,13 @@ export async function readConnections(workspaceRoot: string): Promise<Connection
208
206
  }
209
207
 
210
208
  export async function readWorkspace(workspaceRoot: string): Promise<WorkspaceConfig | null> {
211
- const path = join(workspaceRoot, WORKSPACE_FILE);
212
- const text = await readWorkspaceFile(workspaceFiles(workspaceRoot), WORKSPACE_FILE);
209
+ // New name first: a workspace mid-migration still has the old file, and
210
+ // preferring it would undo the rename on the next write.
211
+ const files = workspaceFiles(workspaceRoot);
212
+ const current = await readWorkspaceFile(files, WORKSPACE_FILE);
213
+ const text = current ?? (await readWorkspaceFile(files, LEGACY_WORKSPACE_FILE));
213
214
  if (text === null) return null;
215
+ const path = join(workspaceRoot, current === null ? LEGACY_WORKSPACE_FILE : WORKSPACE_FILE);
214
216
 
215
217
  const parsed = workspaceSchema.safeParse(parseYaml(text));
216
218
  if (!parsed.success) {
@@ -223,15 +225,34 @@ export async function readWorkspace(workspaceRoot: string): Promise<WorkspaceCon
223
225
 
224
226
  export async function listProfiles(workspaceRoot: string): Promise<string[]> {
225
227
  try {
226
- const entries = await workspaceFiles(workspaceRoot).list('profiles/');
227
- return entries
228
- .map((entry) => entry.key.slice('profiles/'.length))
229
- .filter((name) => name.endsWith('.yaml') && !name.endsWith('.example.yaml'))
230
- // Direct children only: a nested directory under `profiles/` is not a
231
- // profile, and a bucket listing is flat so it would otherwise look like one.
232
- .filter((name) => !name.includes('/'))
233
- .map((name) => name.slice(0, -'.yaml'.length))
234
- .sort();
228
+ const root = `${layout.profilesRoot()}/`;
229
+ const entries = await workspaceFiles(workspaceRoot).list(root);
230
+
231
+ // A profile is a *directory* holding a `profile.yaml` (ADR-067), so the
232
+ // shape matched is `<name>/profile.yaml`. The listing walks each profile's
233
+ // memory and assets on the way past, which is the price of the declaration
234
+ // sitting beside the bytes; `audit.log/` is the workspace's, so the largest
235
+ // collection is not in here.
236
+ //
237
+ // **Both shapes**, because every migration enumerates through here: listing
238
+ // only the new one leaves an unmigrated workspace holding no profiles as
239
+ // far as its own migration is concerned. Same rule as the marker file.
240
+ const names = new Set<string>();
241
+ for (const entry of entries) {
242
+ const rest = entry.key.slice(root.length);
243
+ const parts = rest.split('/');
244
+
245
+ if (parts.length === 2 && parts[1] === PROFILE_FILE) {
246
+ names.add(parts[0]!);
247
+ continue;
248
+ }
249
+
250
+ // Contract 3 and earlier. `.example.yaml` was never a profile.
251
+ if (parts.length === 1 && rest.endsWith('.yaml') && !rest.endsWith('.example.yaml')) {
252
+ names.add(rest.slice(0, -'.yaml'.length));
253
+ }
254
+ }
255
+ return [...names].sort();
235
256
  } catch {
236
257
  return [];
237
258
  }
@@ -254,8 +275,23 @@ export async function resolveSelection(options: ResolveOptions = {}): Promise<Pr
254
275
  if (!profile) throw noProfileNamed(workspaceRoot, await listProfiles(workspaceRoot), env);
255
276
 
256
277
  const path = profilePath(workspaceRoot, profile);
257
- if (!(await workspaceFiles(workspaceRoot).has(`profiles/${profile}.yaml`))) {
278
+ const files = workspaceFiles(workspaceRoot);
279
+
280
+ if (!(await files.has(layout.profileConfig(profile)))) {
258
281
  const available = await listProfiles(workspaceRoot);
282
+
283
+ // **It exists, at the path contract 3 kept it.** Without this the refusal
284
+ // reads "profile personal does not exist. Available: personal" — what a
285
+ // listing that understands both layouts and a lookup that understands one
286
+ // produce together — and names no way forward (ADR-051).
287
+ if (await files.has(legacyProfileConfig(profile))) {
288
+ throw new ConfigError(
289
+ `Profile "${profile}" is still laid out the way contract 3 kept it, and nothing reads ` +
290
+ `that any more.\n Migrate it with: lanes link doctor --fix --profile ${profile} ` +
291
+ '--workspace <name>',
292
+ );
293
+ }
294
+
259
295
  throw new ConfigError(
260
296
  `Profile "${profile}" does not exist (looked for ${path}).\n` +
261
297
  (available.length > 0 ? `Available: ${available.join(', ')}` : 'No profiles exist yet.'),
@@ -72,7 +72,7 @@ const MAX_ASSET_BYTES = 25 * 1024 * 1024;
72
72
  const MAX_TEXT_BYTES = 256 * 1024;
73
73
 
74
74
  export const assetsProvider: ProviderDefinition = defineLocalProvider({
75
- id: 'assets',
75
+ id: 'lanes_assets',
76
76
  name: 'Assets',
77
77
  version: '1.0.0',
78
78
  description:
@@ -104,12 +104,12 @@ export const assetsProvider: ProviderDefinition = defineLocalProvider({
104
104
  title: 'Stored file',
105
105
  description:
106
106
  'One stored file, addressed by its name. Text comes back as text; anything else is described rather than encoded.',
107
- uriTemplate: 'assets://file/{name}',
107
+ uriTemplate: 'lanes-assets://file/{name}',
108
108
  redact: keepKeys('uri'),
109
109
 
110
110
  async list(context) {
111
111
  return (await allAssets(context.storage)).map((asset) => ({
112
- uri: `assets://file/${encodeURIComponent(asset.name)}`,
112
+ uri: `lanes-assets://file/${encodeURIComponent(asset.name)}`,
113
113
  name: asset.name,
114
114
  }));
115
115
  },
@@ -171,7 +171,7 @@ export const assetsProvider: ProviderDefinition = defineLocalProvider({
171
171
  ...shown.flatMap((asset) => [
172
172
  {
173
173
  type: 'resource_link' as const,
174
- uri: `assets://file/${encodeURIComponent(asset.name)}`,
174
+ uri: `lanes-assets://file/${encodeURIComponent(asset.name)}`,
175
175
  name: asset.name,
176
176
  },
177
177
  { type: 'text' as const, text: describeAsset(asset) },
@@ -194,7 +194,7 @@ export const assetsProvider: ProviderDefinition = defineLocalProvider({
194
194
  name: 'get',
195
195
  title: 'Read a stored file',
196
196
  description:
197
- 'Return a text file\'s contents. A binary file is described instead — name, type, size, digest — because encoding it here is the cost this provider exists to avoid. The resource assets://file/{name} is the same content.',
197
+ 'Return a text file\'s contents. A binary file is described instead — name, type, size, digest — because encoding it here is the cost this provider exists to avoid. The resource lanes-assets://file/{name} is the same content.',
198
198
  inputSchema: z.object({ name: z.string().min(1).describe('The file name') }),
199
199
  redact: keepKeys('name'),
200
200
  async handler({ name }, context) {
@@ -271,7 +271,7 @@ export const assetsProvider: ProviderDefinition = defineLocalProvider({
271
271
  },
272
272
  {
273
273
  type: 'resource_link',
274
- uri: `assets://file/${encodeURIComponent(assetName)}`,
274
+ uri: `lanes-assets://file/${encodeURIComponent(assetName)}`,
275
275
  name: assetName,
276
276
  },
277
277
  ],
@@ -58,7 +58,7 @@ const DESCRIPTION =
58
58
  'so more than one means ask rather than take the first.';
59
59
 
60
60
  export const entitiesProvider: ProviderDefinition = defineLocalProvider({
61
- id: 'entities',
61
+ id: 'lanes_entities',
62
62
  name: 'Entities',
63
63
  version: '1.0.0',
64
64
  description: DESCRIPTION,
@@ -99,14 +99,14 @@ export const entitiesProvider: ProviderDefinition = defineLocalProvider({
99
99
  name: 'entity',
100
100
  title: 'Entity',
101
101
  description: 'One declared entity, addressed by its id.',
102
- uriTemplate: 'entities://entity/{id}',
102
+ uriTemplate: 'lanes-entities://entity/{id}',
103
103
  mimeType: 'text/markdown',
104
104
  redact: keepKeys('uri'),
105
105
 
106
106
  async list(context) {
107
107
  const catalogue = await openCatalogue(context.storage);
108
108
  return catalogue.entities.map((entity) => ({
109
- uri: `entities://entity/${encodeURIComponent(entity.id)}`,
109
+ uri: `lanes-entities://entity/${encodeURIComponent(entity.id)}`,
110
110
  name: entity.name,
111
111
  }));
112
112
  },
@@ -230,7 +230,7 @@ export const entitiesProvider: ProviderDefinition = defineLocalProvider({
230
230
  { type: 'text', text: renderCandidates(matches, criteria, context.connection.key) },
231
231
  ...matches.candidates.map((one) => ({
232
232
  type: 'resource_link' as const,
233
- uri: `entities://entity/${encodeURIComponent(one.entity.id)}`,
233
+ uri: `lanes-entities://entity/${encodeURIComponent(one.entity.id)}`,
234
234
  name: one.entity.name,
235
235
  })),
236
236
  ],
@@ -244,7 +244,7 @@ export const entitiesProvider: ProviderDefinition = defineLocalProvider({
244
244
  content: [
245
245
  {
246
246
  type: 'resource_link' as const,
247
- uri: `entities://entity/${encodeURIComponent(found.id)}`,
247
+ uri: `lanes-entities://entity/${encodeURIComponent(found.id)}`,
248
248
  name: found.name,
249
249
  },
250
250
  { type: 'text', text: renderEntity(found, catalogue, file?.body ?? '') },
@@ -259,7 +259,7 @@ export const entitiesProvider: ProviderDefinition = defineLocalProvider({
259
259
  title: 'Read one entity',
260
260
  description:
261
261
  'Return one entity by id, with its relationships and everything pointing at it. The resource ' +
262
- 'entities://entity/{id} is the same content; this exists for clients that do not read resources.',
262
+ 'lanes-entities://entity/{id} is the same content; this exists for clients that do not read resources.',
263
263
  inputSchema: z.object({ id: z.string().min(1).describe('Entity id') }),
264
264
  redact: keepKeys('id'),
265
265
  async handler({ id }, context) {
@@ -120,7 +120,7 @@ export const writeCapabilities: readonly Capability[] = [
120
120
  type: 'text',
121
121
  text: `${existing === null ? 'Declared' : 'Updated'} "${id}" on ${context.connection.key}.`,
122
122
  },
123
- { type: 'resource_link', uri: `entities://entity/${id}`, name },
123
+ { type: 'resource_link', uri: `lanes-entities://entity/${id}`, name },
124
124
  ],
125
125
  };
126
126
  },
@@ -65,7 +65,7 @@ export function createIdentityProvider(options: IdentityProviderOptions): Provid
65
65
  const entries = options.entries ?? [];
66
66
 
67
67
  return defineLocalProvider({
68
- id: 'identity',
68
+ id: 'lanes_identity',
69
69
  name: 'Identity',
70
70
  version: '1.0.0',
71
71
  description:
@@ -172,7 +172,7 @@ function slugify(title: string): string {
172
172
  }
173
173
 
174
174
  export const memoryProvider: ProviderDefinition = defineLocalProvider({
175
- id: 'memory',
175
+ id: 'lanes_memory',
176
176
  name: 'Memory',
177
177
  version: '1.0.0',
178
178
  description:
@@ -212,7 +212,7 @@ export const memoryProvider: ProviderDefinition = defineLocalProvider({
212
212
  name: 'entry',
213
213
  title: 'Memory entry',
214
214
  description: 'One stored memory entry, addressed by its id.',
215
- uriTemplate: 'memory://entry/{id}',
215
+ uriTemplate: 'lanes-memory://entry/{id}',
216
216
  mimeType: 'text/markdown',
217
217
  // The address is worth recording and the content is not — the same trade
218
218
  // `gmail.get_message` makes with a message id.
@@ -220,7 +220,7 @@ export const memoryProvider: ProviderDefinition = defineLocalProvider({
220
220
 
221
221
  async list(context) {
222
222
  return (await allEntries(context.storage)).map((entry) => ({
223
- uri: `memory://entry/${encodeURIComponent(entry.id)}`,
223
+ uri: `lanes-memory://entry/${encodeURIComponent(entry.id)}`,
224
224
  name: entry.title,
225
225
  }));
226
226
  },
@@ -242,7 +242,7 @@ export const memoryProvider: ProviderDefinition = defineLocalProvider({
242
242
  name: 'get',
243
243
  title: 'Read a memory entry',
244
244
  description:
245
- 'Return one entry by id. The resource memory://entry/{id} is the same content; this exists for clients that do not read resources.',
245
+ 'Return one entry by id. The resource lanes-memory://entry/{id} is the same content; this exists for clients that do not read resources.',
246
246
  inputSchema: z.object({
247
247
  id: z.string().min(1).describe('Entry id'),
248
248
  }),
@@ -326,7 +326,7 @@ export const memoryProvider: ProviderDefinition = defineLocalProvider({
326
326
  content: matches.flatMap(({ entry, snippet }) => [
327
327
  {
328
328
  type: 'resource_link' as const,
329
- uri: `memory://entry/${encodeURIComponent(entry.id)}`,
329
+ uri: `lanes-memory://entry/${encodeURIComponent(entry.id)}`,
330
330
  name: entry.title,
331
331
  },
332
332
  {
@@ -370,7 +370,7 @@ export const memoryProvider: ProviderDefinition = defineLocalProvider({
370
370
  return {
371
371
  content: [
372
372
  { type: 'text', text: `Stored memory entry "${entryId}" on ${context.connection.key}.` },
373
- { type: 'resource_link', uri: `memory://entry/${entryId}`, name: title },
373
+ { type: 'resource_link', uri: `lanes-memory://entry/${entryId}`, name: title },
374
374
  ],
375
375
  };
376
376
  },
@@ -92,7 +92,7 @@ export function createSetupProvider(options: SetupProviderOptions): ProviderDefi
92
92
  });
93
93
 
94
94
  return defineLocalProvider({
95
- id: 'setup',
95
+ id: 'lanes_setup',
96
96
  name: 'Setup',
97
97
  version: '1.0.0',
98
98
  description:
@@ -147,7 +147,7 @@ export function createSetupProvider(options: SetupProviderOptions): ProviderDefi
147
147
  'The console steps, the values needed, and the exact command that connects it. ' +
148
148
  'Use this to tell the owner what to run — do not compose the command yourself.',
149
149
  inputSchema: z.object({
150
- id: z.string().min(1).describe('Provider id, as listed by setup_overview — e.g. "notion"'),
150
+ id: z.string().min(1).describe('Provider id, as listed by lanes_setup_overview — e.g. "notion"'),
151
151
  connection: z
152
152
  .string()
153
153
  .optional()
@@ -167,7 +167,7 @@ export function createSetupProvider(options: SetupProviderOptions): ProviderDefi
167
167
  {
168
168
  type: 'text',
169
169
  text:
170
- `No provider "${id}". Call setup_overview for the ids this endpoint knows.`,
170
+ `No provider "${id}". Call lanes_setup_overview for the ids this endpoint knows.`,
171
171
  },
172
172
  ],
173
173
  isError: true,
@@ -110,7 +110,7 @@ export function createSkillsProvider(options: SkillsProviderOptions): ProviderDe
110
110
  const manage = options.store ? managementCapabilities(options.store, options.onChange) : [];
111
111
 
112
112
  return defineLocalProvider({
113
- id: 'skills',
113
+ id: 'lanes_skills',
114
114
  name: 'Skills',
115
115
  version: '1.0.0',
116
116
  description:
@@ -246,7 +246,7 @@ function managementCapabilities(
246
246
  content: [
247
247
  {
248
248
  type: 'text',
249
- text: `Stored skill "${skill.name}" on ${context.connection.key}. It is available as the prompt "skills_${skill.name}" where policy allows it.`,
249
+ text: `Stored skill "${skill.name}" on ${context.connection.key}. It is available as the prompt "lanes_skills_${skill.name}" where policy allows it.`,
250
250
  },
251
251
  ],
252
252
  };