@lanes-sh/link 0.7.2 → 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 (190) hide show
  1. package/README.md +20 -10
  2. package/instructions/agents/lanes-link-scout.md +2 -2
  3. package/instructions/skills/lanes-link/SKILL.md +148 -73
  4. package/package.json +2 -1
  5. package/src/audit/index.ts +8 -1
  6. package/src/auth/index.ts +58 -2
  7. package/src/auth/lanes/assertion.ts +256 -0
  8. package/src/auth/lanes/callback.ts +135 -0
  9. package/src/auth/lanes/federation.ts +50 -0
  10. package/src/auth/lanes/login.ts +294 -0
  11. package/src/auth/lanes/members.ts +103 -0
  12. package/src/auth/lanes/session.ts +97 -0
  13. package/src/auth/oauth/grant.ts +183 -0
  14. package/src/auth/oauth/result.ts +27 -0
  15. package/src/auth/oauth/server.ts +176 -203
  16. package/src/auth/oauth/store.ts +85 -4
  17. package/src/auth/remote.ts +32 -9
  18. package/src/cli/accepts.ts +109 -0
  19. package/src/cli/argv.ts +57 -3
  20. package/src/cli/audit-change.ts +140 -0
  21. package/src/cli/callback-page.ts +36 -115
  22. package/src/cli/commands/auth-dispatch.ts +48 -0
  23. package/src/cli/commands/auth.ts +229 -0
  24. package/src/cli/commands/connect/accounts.ts +4 -4
  25. package/src/cli/commands/connect/authorise.ts +4 -4
  26. package/src/cli/commands/connect/bind-credential.ts +2 -1
  27. package/src/cli/commands/connect/custom/index.ts +1 -1
  28. package/src/cli/commands/connect/custom/write.ts +2 -2
  29. package/src/cli/commands/connect/grant.ts +29 -14
  30. package/src/cli/commands/connect/index.ts +90 -88
  31. package/src/cli/commands/connect/options.ts +83 -0
  32. package/src/cli/commands/connect/registration.ts +50 -0
  33. package/src/cli/commands/connect/requirements.ts +1 -1
  34. package/src/cli/commands/connect/settle.ts +16 -6
  35. package/src/cli/commands/connect/target-note.ts +7 -2
  36. package/src/cli/commands/connect/unknown.ts +1 -1
  37. package/src/cli/commands/connect/variables.ts +3 -2
  38. package/src/cli/commands/connection-list.ts +116 -0
  39. package/src/cli/commands/connection.ts +183 -165
  40. package/src/cli/commands/grant.ts +140 -0
  41. package/src/cli/commands/identity.ts +24 -12
  42. package/src/cli/commands/knowledge/index.ts +49 -89
  43. package/src/cli/commands/knowledge/migrate.ts +79 -13
  44. package/src/cli/commands/knowledge/show.ts +97 -0
  45. package/src/cli/commands/knowledge.ts +2 -1
  46. package/src/cli/commands/mcp/harnesses.ts +30 -8
  47. package/src/cli/commands/mcp/onboarding.ts +86 -0
  48. package/src/cli/commands/mcp/register.ts +16 -2
  49. package/src/cli/commands/mcp.ts +1 -0
  50. package/src/cli/commands/members.ts +288 -0
  51. package/src/cli/commands/operate/attach.ts +3 -3
  52. package/src/cli/commands/operate/audit.ts +11 -7
  53. package/src/cli/commands/operate/auth.ts +28 -11
  54. package/src/cli/commands/operate/findings.ts +2 -1
  55. package/src/cli/commands/operate/inspect.ts +37 -19
  56. package/src/cli/commands/operate/migrate.ts +33 -13
  57. package/src/cli/commands/operate/outputs.ts +3 -3
  58. package/src/cli/commands/operate/pair-certificate.ts +141 -0
  59. package/src/cli/commands/operate/pair.ts +324 -0
  60. package/src/cli/commands/operate/policy.ts +73 -22
  61. package/src/cli/commands/operate/serve.ts +53 -5
  62. package/src/cli/commands/operate/status.ts +18 -10
  63. package/src/cli/commands/operate/tools.ts +2 -2
  64. package/src/cli/commands/operate.ts +2 -0
  65. package/src/cli/commands/owner/assets.ts +2 -2
  66. package/src/cli/commands/owner/entities.ts +2 -2
  67. package/src/cli/commands/owner/memory.ts +2 -2
  68. package/src/cli/commands/owner/shared.ts +13 -2
  69. package/src/cli/commands/owner/skills.ts +28 -8
  70. package/src/cli/commands/owner/tasks.ts +2 -2
  71. package/src/cli/commands/owner/vault.ts +3 -3
  72. package/src/cli/commands/profile/disposition.ts +236 -0
  73. package/src/cli/commands/profile/removal.ts +154 -64
  74. package/src/cli/commands/profile/remove.ts +83 -7
  75. package/src/cli/commands/profile.ts +79 -16
  76. package/src/cli/commands/relabel.ts +112 -0
  77. package/src/cli/commands/secrets.ts +39 -17
  78. package/src/cli/commands/set-workspace.ts +96 -0
  79. package/src/cli/commands/setup.ts +2 -2
  80. package/src/cli/commands/sync.ts +8 -8
  81. package/src/cli/commands/target.ts +9 -7
  82. package/src/cli/commands/update-migration.ts +54 -0
  83. package/src/cli/commands/update.ts +78 -24
  84. package/src/cli/config-edit.ts +99 -143
  85. package/src/cli/config-migrate.ts +82 -64
  86. package/src/cli/config-repair-sweep.ts +119 -0
  87. package/src/cli/config-repair.ts +131 -125
  88. package/src/cli/config-templates.ts +200 -0
  89. package/src/cli/contract3-credentials.ts +294 -0
  90. package/src/cli/contract3-data.ts +262 -0
  91. package/src/cli/contract3-layout.ts +46 -0
  92. package/src/cli/contract3-shape.ts +212 -0
  93. package/src/cli/contract3.ts +399 -0
  94. package/src/cli/contract4-credentials.ts +207 -0
  95. package/src/cli/contract4-data.ts +399 -0
  96. package/src/cli/contract4-rename.ts +73 -0
  97. package/src/cli/contract4-yaml.ts +223 -0
  98. package/src/cli/contract4.ts +342 -0
  99. package/src/cli/endpoint-url.ts +1 -1
  100. package/src/cli/identity.ts +44 -26
  101. package/src/cli/lanes.ts +25 -1
  102. package/src/cli/main.ts +94 -14
  103. package/src/cli/migrate-move.ts +166 -0
  104. package/src/cli/migrate-plan.ts +12 -6
  105. package/src/cli/output.ts +34 -1
  106. package/src/cli/publish.ts +6 -7
  107. package/src/cli/runtime/open.ts +64 -99
  108. package/src/cli/runtime/registry.ts +6 -7
  109. package/src/cli/runtime/select.ts +2 -11
  110. package/src/cli/runtime/stores.ts +58 -0
  111. package/src/cli/runtime/types.ts +106 -0
  112. package/src/cli/runtime/vault.ts +19 -4
  113. package/src/cli/runtime/workspace.ts +60 -0
  114. package/src/cli/runtime.ts +2 -1
  115. package/src/cli/selection-require.ts +44 -13
  116. package/src/cli/selection.ts +127 -145
  117. package/src/cli/usage.ts +40 -20
  118. package/src/cli/workspace-migrate.ts +152 -22
  119. package/src/connectivity/manifest/provider.ts +34 -13
  120. package/src/connectivity/manifest/requirements.ts +1 -1
  121. package/src/connectivity/transports/imap/parser.ts +70 -9
  122. package/src/deployments/adapters/filesystem.ts +18 -3
  123. package/src/deployments/bind.ts +1 -1
  124. package/src/deployments/deploy.ts +38 -29
  125. package/src/deployments/gcp/bucket.ts +58 -11
  126. package/src/deployments/gcp/provision.ts +7 -7
  127. package/src/deployments/knowledge.ts +9 -4
  128. package/src/deployments/prepare.ts +72 -24
  129. package/src/deployments/record.ts +1 -1
  130. package/src/deployments/report.ts +2 -2
  131. package/src/deployments/serving.ts +15 -74
  132. package/src/deployments/target.ts +34 -13
  133. package/src/deployments/upload.ts +60 -27
  134. package/src/dispatch/deps.ts +88 -0
  135. package/src/dispatch/dispatch.ts +21 -62
  136. package/src/policy/index.ts +47 -15
  137. package/src/profile/connections.ts +195 -0
  138. package/src/profile/deployments.ts +86 -8
  139. package/src/profile/index.ts +35 -6
  140. package/src/profile/knowledge.ts +18 -5
  141. package/src/profile/layout.ts +163 -90
  142. package/src/profile/load.ts +133 -64
  143. package/src/profile/pairing.ts +32 -0
  144. package/src/profile/primitives.ts +35 -1
  145. package/src/profile/registry.ts +6 -6
  146. package/src/profile/schema.ts +181 -21
  147. package/src/profile/targets.ts +21 -9
  148. package/src/profile/testing.ts +104 -2
  149. package/src/profile/workspace.ts +124 -33
  150. package/src/providers/assets/provider.ts +6 -6
  151. package/src/providers/custom/index.ts +1 -1
  152. package/src/providers/custom/load.ts +2 -3
  153. package/src/providers/entities/provider.ts +6 -6
  154. package/src/providers/entities/writes.ts +1 -1
  155. package/src/providers/identity/provider.ts +2 -2
  156. package/src/providers/memory/provider.ts +26 -8
  157. package/src/providers/setup/plan.ts +1 -1
  158. package/src/providers/setup/provider.ts +3 -3
  159. package/src/providers/skills/provider.ts +2 -2
  160. package/src/providers/slack/index.ts +2 -2
  161. package/src/providers/tasks/provider.ts +6 -6
  162. package/src/providers/vault/provider.ts +1 -1
  163. package/src/registry/policy-bridge.ts +33 -11
  164. package/src/registry/reconcile.ts +4 -4
  165. package/src/registry/registry.ts +1 -1
  166. package/src/server/authorization.ts +94 -0
  167. package/src/server/edge.ts +14 -1
  168. package/src/server/endpoint.ts +89 -104
  169. package/src/server/generation.ts +10 -1
  170. package/src/server/harness.ts +71 -13
  171. package/src/server/index.ts +31 -0
  172. package/src/server/mcp/build.ts +20 -1
  173. package/src/server/mcp/client-info.ts +54 -0
  174. package/src/server/mcp/guide.ts +120 -0
  175. package/src/server/mcp/instructions.ts +22 -22
  176. package/src/server/mcp/prompts.ts +7 -3
  177. package/src/server/mcp/resources.ts +16 -8
  178. package/src/server/mcp/routing.ts +3 -3
  179. package/src/server/mcp/tools.ts +25 -6
  180. package/src/server/mcp/visibility.ts +74 -7
  181. package/src/server/oauth.ts +29 -109
  182. package/src/server/read/credential.ts +134 -0
  183. package/src/server/read/deployed.ts +56 -0
  184. package/src/server/read/listener.ts +54 -0
  185. package/src/server/read/open.ts +101 -0
  186. package/src/server/read/routes.ts +247 -0
  187. package/src/server/read/state.ts +171 -0
  188. package/src/stores/blobs/conformance.ts +19 -0
  189. package/src/stores/state/index.ts +76 -10
  190. package/src/stores/state/testing.ts +5 -1
@@ -11,6 +11,7 @@
11
11
  * outputs.ts what an agent harness needs, and proving the short form works
12
12
  * desktop.ts opening the Lanes app, on the page that drives this CLI
13
13
  * serve.ts start
14
+ * pair.ts pair — the dashboard's read surface (ADR-063)
14
15
  * audit.ts audit tail and verify, and the Markdown rendering of tail
15
16
  * token.ts token show, token rotate
16
17
  * policy.ts policy list/allow/deny, config show
@@ -25,6 +26,7 @@ export { status } from './operate/status.ts';
25
26
  export { outputs, type OutputsFlags } from './operate/outputs.ts';
26
27
  export { tools, type ToolsFlags } from './operate/tools.ts';
27
28
  export { start } from './operate/serve.ts';
29
+ export { pair, PAIR_CERT_REF, PAIR_KEY_REF, PAIR_TOKEN_REF, type PairFlags } from './operate/pair.ts';
28
30
  export { desktop, settingsUrl, type DesktopFlags } from './operate/desktop.ts';
29
31
  export { auditTail, auditVerify, markdownCell } from './operate/audit.ts';
30
32
  export { attachFile } from './operate/attach.ts';
@@ -127,6 +127,6 @@ export async function assetsRemove(name: string | undefined, flags: OwnerFlags):
127
127
  * it is built from the same two functions rather than spelled as a path.
128
128
  */
129
129
  export function assetsStore(runtime: Runtime, flags: OwnerFlags): BlobStore {
130
- const connection = ownerConnection(runtime.config, 'assets', flags);
131
- return scopeBlobStore(runtime.storage, scopeNamespace('assets', connection));
130
+ const connection = ownerConnection(runtime.config, 'lanes_assets', flags);
131
+ return scopeBlobStore(runtime.storage, scopeNamespace('lanes_assets', connection));
132
132
  }
@@ -239,6 +239,6 @@ export async function entitiesReindex(flags: OwnerFlags): Promise<void> {
239
239
  * `memoryStore` gives: a path spelled out again is a path that can differ.
240
240
  */
241
241
  export function entitiesStore(runtime: Runtime, flags: OwnerFlags): BlobStore {
242
- const connection = ownerConnection(runtime.config, 'entities', flags);
243
- return scopeBlobStore(runtime.storage, scopeNamespace('entities', connection));
242
+ const connection = ownerConnection(runtime.config, 'lanes_entities', flags);
243
+ return scopeBlobStore(runtime.storage, scopeNamespace('lanes_entities', connection));
244
244
  }
@@ -105,6 +105,6 @@ export async function memoryForget(id: string | undefined, flags: OwnerFlags): P
105
105
  * again, so the CLI cannot address a different directory from the provider.
106
106
  */
107
107
  export function memoryStore(runtime: Runtime, flags: OwnerFlags): BlobStore {
108
- const connection = ownerConnection(runtime.config, 'memory', flags);
109
- return scopeBlobStore(runtime.storage, scopeNamespace('memory', connection));
108
+ const connection = ownerConnection(runtime.config, 'lanes_memory', flags);
109
+ return scopeBlobStore(runtime.storage, scopeNamespace('lanes_memory', connection));
110
110
  }
@@ -73,7 +73,15 @@ export async function withRuntime(
73
73
  * the candidates rather than picking.
74
74
  */
75
75
  export function ownerConnection(config: Config, provider: string, flags: OwnerFlags): string {
76
- const candidates = config.connections.filter((connection) => connection.provider === provider);
76
+ // Derived from the grants, not from the workspace's connections. The question
77
+ // is which store *this profile* may reach, and a workspace holding
78
+ // `memory.acme` that this profile does not grant is not a candidate — offering
79
+ // it would let the CLI write somewhere the endpoint would refuse to read
80
+ // (ADR-057, ADR-058).
81
+ const prefix = `${provider}.`;
82
+ const candidates = config.grants
83
+ .filter((grant) => grant.connection.startsWith(prefix))
84
+ .map((grant) => ({ id: grant.connection.slice(prefix.length) }));
77
85
 
78
86
  if (flags.connection) {
79
87
  const match = candidates.find((connection) => connection.id === flags.connection);
@@ -90,7 +98,10 @@ export function ownerConnection(config: Config, provider: string, flags: OwnerFl
90
98
 
91
99
  if (candidates.length === 0) {
92
100
  throw new ConfigError(
93
- `This profile has no ${provider} connection. Add one with: lanes link connect ${provider}`,
101
+ `This profile grants no ${provider} connection.\n` +
102
+ ` Connect one to the workspace, then grant it:\n` +
103
+ ` lanes link connect ${provider}\n` +
104
+ ` lanes link grant ${provider}.<id> --profile ${config.instance.profile}`,
94
105
  );
95
106
  }
96
107
  if (candidates.length > 1) {
@@ -1,4 +1,5 @@
1
- import { ConfigError } from '#profile';
1
+ import { ConfigError, type Config } from '#profile';
2
+ import type { BlobStore } from '#stores/blobs';
2
3
  import {
3
4
  loadProfileSkills,
4
5
  readSkill,
@@ -10,9 +11,28 @@ import { agreed, readStdin, required, withRuntime, type OwnerFlags } from './sha
10
11
 
11
12
  /** `lanes link skills` — the reusable procedures agents can invoke. */
12
13
 
14
+ /**
15
+ * The store, or a refusal naming why there is none.
16
+ *
17
+ * A profile grants at most one `skills` connection and may grant none
18
+ * (ADR-059), so `store(runtime)` is genuinely absent rather than empty. The two
19
+ * are worth telling apart: "you have no skills" invites writing one, and
20
+ * "this profile is not granted skills" is a config fact the operator has to fix
21
+ * before writing one would do anything.
22
+ */
23
+ function store(runtime: { skills: BlobStore | undefined; config: Config }): BlobStore {
24
+ if (runtime.skills) return runtime.skills;
25
+ throw new ConfigError(
26
+ `Profile "${runtime.config.instance.profile}" grants no skills connection.\n` +
27
+ ` Grant one, then try again:\n` +
28
+ ` lanes link grant skills.main --profile ${runtime.config.instance.profile}`,
29
+ );
30
+ }
31
+
32
+
13
33
  export async function skillsList(flags: OwnerFlags): Promise<void> {
14
34
  await withRuntime(flags, async (runtime) => {
15
- const skills = await loadProfileSkills(runtime.skills);
35
+ const skills = await loadProfileSkills(store(runtime));
16
36
 
17
37
  heading(`Skills (${skills.length})`);
18
38
  if (skills.length === 0) {
@@ -43,10 +63,10 @@ export async function skillsShow(name: string | undefined, flags: OwnerFlags): P
43
63
  const skillName = required(name, 'lanes link skills show <name>');
44
64
 
45
65
  await withRuntime(flags, async (runtime) => {
46
- const skill = await readSkill(runtime.skills, skillName);
66
+ const skill = await readSkill(store(runtime), skillName);
47
67
  if (!skill) throw new ConfigError(`No skill "${skillName}" in this workspace.`);
48
68
 
49
- const bytes = await runtime.skills.get(skill.path);
69
+ const bytes = await store(runtime).get(skill.path);
50
70
  print('');
51
71
  print(bytes ? new TextDecoder().decode(bytes) : skill.body);
52
72
  });
@@ -63,8 +83,8 @@ export async function skillsAdd(name: string | undefined, flags: OwnerFlags): Pr
63
83
  );
64
84
 
65
85
  await withRuntime(flags, async (runtime) => {
66
- const existing = await readSkill(runtime.skills, skillName);
67
- const skill = await writeSkill(runtime.skills, skillName, text);
86
+ const existing = await readSkill(store(runtime), skillName);
87
+ const skill = await writeSkill(store(runtime), skillName, text);
68
88
 
69
89
  print(ok(`${existing ? 'replaced' : 'added'} skill ${style.bold(skill.name)}`));
70
90
  print(
@@ -79,14 +99,14 @@ export async function skillsRemove(name: string | undefined, flags: OwnerFlags):
79
99
  const skillName = required(name, 'lanes link skills remove <name>');
80
100
 
81
101
  await withRuntime(flags, async (runtime) => {
82
- const skill = await readSkill(runtime.skills, skillName);
102
+ const skill = await readSkill(store(runtime), skillName);
83
103
  if (!skill) throw new ConfigError(`No skill "${skillName}" in this workspace.`);
84
104
 
85
105
  print(` ${style.bold(skill.name)} ${skill.description}`);
86
106
  print(style.dim(` ${skill.path}`));
87
107
  if (!(await agreed(flags, 'Remove this skill?'))) return;
88
108
 
89
- await removeSkill(runtime.skills, skillName);
109
+ await removeSkill(store(runtime), skillName);
90
110
  print(ok(`removed skill ${style.bold(skillName)}`));
91
111
  });
92
112
  }
@@ -189,6 +189,6 @@ function assertStatus(raw: string | undefined): TaskStatus | undefined {
189
189
  * CLI cannot address a different directory from the provider.
190
190
  */
191
191
  export function tasksStore(runtime: Runtime, flags: OwnerFlags): BlobStore {
192
- const connection = ownerConnection(runtime.config, 'tasks', flags);
193
- return scopeBlobStore(runtime.storage, scopeNamespace('tasks', connection));
192
+ const connection = ownerConnection(runtime.config, 'lanes_tasks', flags);
193
+ return scopeBlobStore(runtime.storage, scopeNamespace('lanes_tasks', connection));
194
194
  }
@@ -50,7 +50,7 @@ export async function vaultGet(id: string | undefined, flags: OwnerFlags): Promi
50
50
  const itemId = required(id, 'lanes link vault get <id> [--show]');
51
51
 
52
52
  await withRuntime(flags, async (runtime) => {
53
- const connection = ownerConnection(runtime.config, 'vault', flags);
53
+ const connection = ownerConnection(runtime.config, 'lanes_vault', flags);
54
54
  const item = await runtime.vault.get(connection, itemId);
55
55
  if (!item) throw new ConfigError(`No vault item "${itemId}" in this profile.`);
56
56
 
@@ -77,7 +77,7 @@ export async function vaultSet(id: string | undefined, flags: OwnerFlags): Promi
77
77
  const value = await readStdin(`lanes link vault set ${itemId}`, 'the secret');
78
78
 
79
79
  await withRuntime(flags, async (runtime) => {
80
- const connection = ownerConnection(runtime.config, 'vault', flags);
80
+ const connection = ownerConnection(runtime.config, 'lanes_vault', flags);
81
81
  const replacing = (await runtime.vault.get(connection, itemId)) !== null;
82
82
 
83
83
  await runtime.vault.put(connection, {
@@ -104,7 +104,7 @@ export async function vaultRemove(id: string | undefined, flags: OwnerFlags): Pr
104
104
  const itemId = required(id, 'lanes link vault remove <id>');
105
105
 
106
106
  await withRuntime(flags, async (runtime) => {
107
- const connection = ownerConnection(runtime.config, 'vault', flags);
107
+ const connection = ownerConnection(runtime.config, 'lanes_vault', flags);
108
108
  const item = await runtime.vault.get(connection, itemId);
109
109
  if (!item) throw new ConfigError(`No vault item "${itemId}" in this profile.`);
110
110
 
@@ -0,0 +1,236 @@
1
+ import { ConfigError } from '#profile';
2
+ import type { Prompter } from '../../prompt.ts';
3
+
4
+ /**
5
+ * What becomes of the bytes a removed profile owns.
6
+ *
7
+ * Split from `removal.ts` and `remove.ts` on the seam the question already has:
8
+ * both files grew past the size budget when this arrived, and it is one
9
+ * decision — asked once, answered once, and then applied by the plan and the
10
+ * execution without either re-deciding anything.
11
+ */
12
+
13
+ /**
14
+ * What to do with the bytes a profile owns.
15
+ *
16
+ * There has to be an answer and there is no safe default, which is why this is
17
+ * a discriminated choice rather than a flag with a fallback. Deleting by
18
+ * default destroys the material somebody kept; migrating by default puts one
19
+ * profile's notes into another without being asked, which is the thing ADR-066
20
+ * exists to stop happening by accident.
21
+ */
22
+ export type Disposition =
23
+ | { readonly kind: 'delete' }
24
+ | { readonly kind: 'migrate'; readonly into: string };
25
+
26
+ /**
27
+ * `note.md` becomes `note-2.md`, before the extension rather than after it.
28
+ *
29
+ * An asset's key is whatever the file was called, and `note.md-2` is a file
30
+ * nothing will open. Memory entries and tasks are `<id>.md`, so the same rule
31
+ * keeps the id readable.
32
+ */
33
+ export function suffixed(key: string, taken: (candidate: string) => boolean): string {
34
+ const dot = key.lastIndexOf('.');
35
+ const cut = dot <= key.lastIndexOf('/') ? key.length : dot;
36
+ const [stem, extension] = [key.slice(0, cut), key.slice(cut)];
37
+
38
+ for (let n = 2; ; n += 1) {
39
+ const candidate = `${stem}-${n}${extension}`;
40
+ if (!taken(candidate)) return candidate;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * What to do with the bytes, decided before anything is planned.
46
+ *
47
+ * **`--yes` does not imply a disposition.** Everything else `--yes` skips is a
48
+ * confirmation of something the command was already told to do; this is a
49
+ * question it has not been asked, and picking either answer silently is a way
50
+ * to lose somebody's notes — or to put them somewhere they did not ask for
51
+ * them, which under ADR-066 is the failure the whole change exists to prevent.
52
+ * So a non-interactive caller must say, and one that does not is refused with
53
+ * both spellings named.
54
+ */
55
+ export async function settleDisposition(
56
+ profile: string,
57
+ flags: { readonly deleteData?: boolean | undefined; readonly migrateTo?: string | undefined },
58
+ prompter: Prompter,
59
+ someoneToAsk: boolean,
60
+ ): Promise<Disposition | null> {
61
+ if (flags.migrateTo !== undefined && flags.deleteData === true) {
62
+ throw new ConfigError(
63
+ '--delete-data and --migrate-to say opposite things about the same bytes. Pass one.',
64
+ );
65
+ }
66
+
67
+ if (flags.migrateTo !== undefined) return { kind: 'migrate', into: flags.migrateTo };
68
+ if (flags.deleteData === true) return { kind: 'delete' };
69
+
70
+ if (!someoneToAsk) {
71
+ throw new ConfigError(
72
+ `"${profile}" owns memory, tasks, assets and skills, and this does not guess at what ` +
73
+ 'becomes of them.\n' +
74
+ ' Say which: --delete-data, or --migrate-to <profile>',
75
+ );
76
+ }
77
+
78
+ const answer = (
79
+ await prompter.ask(
80
+ `What becomes of ${profile}'s memory, tasks, assets and skills?\n` +
81
+ ` [d] delete them [m] move them into another profile [anything else] stop: `,
82
+ )
83
+ ).trim().toLowerCase();
84
+
85
+ if (answer === 'd') return { kind: 'delete' };
86
+ if (answer !== 'm') return null;
87
+
88
+ const into = (await prompter.ask(' Move them into which profile? ')).trim();
89
+ return into.length === 0 ? null : { kind: 'migrate', into };
90
+ }
91
+
92
+
93
+ /**
94
+ * The skill a key belongs to, or null when the key is not a skill's.
95
+ *
96
+ * Both layouts: `<name>/SKILL.md` and whatever it ships beside it, and the flat
97
+ * `<name>.md`.
98
+ */
99
+ export function skillNameIn(key: string): string | null {
100
+ const parts = key.split('/');
101
+ if (parts[0] !== 'skills.d' || parts.length < 3) return null;
102
+
103
+ const third = parts[2]!;
104
+ return parts.length > 3 ? third : third.replace(/\.md$/, '');
105
+ }
106
+
107
+ /**
108
+ * Two skills of one name have no union either, so this refuses like the vault.
109
+ *
110
+ * A skill's name is not a filename — it becomes the capability id
111
+ * `skills.<name>`, which is what a policy rule grants and what an MCP prompt is
112
+ * called. So the suffix that resolves a colliding note resolves nothing here: a
113
+ * skill arriving as `proc-b-2` is granted by no rule the destination holds and
114
+ * offered to no client, which is `refuseSealedVault`'s argument — a copy under a
115
+ * name no command opens — reached by a different route. Renaming only the
116
+ * directory is worse still, because the frontmatter keeps declaring the old
117
+ * name: two skills then claim one capability id and `skills list` refuses for
118
+ * the whole profile rather than for the one skill.
119
+ *
120
+ * Which of the two to keep is a decision about the operator's procedures, and
121
+ * they are the only one who can make it.
122
+ */
123
+ export function refuseCollidingSkill(into: string, name: string): never {
124
+ throw new ConfigError(
125
+ `"${into}" already has a skill named "${name}", and the one arriving cannot take another ` +
126
+ `name: it becomes the capability id "skills.${name}", which policy rules grant and MCP ` +
127
+ 'prompts are called by.\n' +
128
+ ` Rename one of the two first — lanes link skills show ${name} --profile ${into} — then ` +
129
+ 'run this again, or remove the profile with --delete-data.',
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Rename anything the destination already holds, before a byte moves.
135
+ *
136
+ * Resolved while this is still a plan, so the operator sees every rename in the
137
+ * preview they confirm from and the execution has no decision left to make. A
138
+ * destination that is occupied holds the *destination profile's* own note, task
139
+ * or asset — overwriting one would be the quiet half of a migration nobody
140
+ * asked for, and merging two is not a thing bytes can do.
141
+ *
142
+ * Mutates `items` in place because the caller is still assembling the plan;
143
+ * the warnings come back for it to attach.
144
+ */
145
+ export async function resolveCollisions(
146
+ items: { kind: string; movedTo?: readonly [string, string]; note?: string }[],
147
+ profile: string,
148
+ into: string,
149
+ open: (area: string) => Promise<{ list(): Promise<{ key: string }[]> }>,
150
+ ): Promise<string[]> {
151
+ const warnings: string[] = [];
152
+
153
+ // **Throws rather than warning.** It returned a warning saying "nothing has
154
+ // run" and then left every `movedTo` in place — and `executeRemoval` writes
155
+ // each one without checking, on the stated ground that "a collision was
156
+ // resolved while this was still a plan". With `--yes` there is no prompt to
157
+ // stop at, so an unreadable destination meant the destination profile's own
158
+ // notes were overwritten by the removed profile's files of the same name, and
159
+ // the command reported success. A plan that cannot be made safe must not
160
+ // become one.
161
+ let held: Set<string>;
162
+ try {
163
+ held = new Set((await (await open(into)).list()).map((blob) => blob.key));
164
+ } catch (cause) {
165
+ throw new ConfigError(
166
+ `"${profile}"'s storage could not be read (${
167
+ cause instanceof Error ? cause.message : String(cause)
168
+ }), so a name it already holds cannot be found — and migrating into it ` +
169
+ 'would overwrite one.\n Nothing has run. Fix the store and try again, or pass ' +
170
+ '--delete-data instead.',
171
+ );
172
+ }
173
+
174
+ for (let index = 0; index < items.length; index += 1) {
175
+ const item = items[index]!;
176
+ if (item.kind !== 'blob' || item.movedTo === undefined) continue;
177
+
178
+ const key = item.movedTo[1];
179
+ if (!held.has(key)) {
180
+ held.add(key);
181
+ continue;
182
+ }
183
+
184
+ // Before the suffix, because for one kind of key there is no suffix that
185
+ // works and the answer is to stop rather than to invent one.
186
+ const skill = skillNameIn(key);
187
+ if (skill !== null) refuseCollidingSkill(profile, skill);
188
+
189
+ const renamed = suffixed(key, (candidate) => held.has(candidate));
190
+ held.add(renamed);
191
+ items[index] = {
192
+ ...item,
193
+ movedTo: [into, renamed] as const,
194
+ note: `renamed — ${profile} already holds ${key}`,
195
+ };
196
+ warnings.push(`${key} is already in "${profile}", so it arrives as ${renamed}.`);
197
+ }
198
+
199
+ return warnings;
200
+ }
201
+
202
+ /**
203
+ * Whether one key inside a profile's directory can cross into another profile.
204
+ *
205
+ * Only the owner's material can. The prompt asks about memory, tasks, assets
206
+ * and skills; the directory holds two more things and neither may travel.
207
+ *
208
+ * `state.kv/` is derived and disposable, and adopting it would hand the
209
+ * destination another profile's cursor — resuming a mailbox from a position it
210
+ * never read and never seeing the messages in between, which is the
211
+ * cross-profile cursor sharing ADR-066 exists to remove.
212
+ *
213
+ * `vault.d/` is refused by `refuseSealedVault` rather than copied. Because the
214
+ * owner layer merges to one row per surface, both profiles hold
215
+ * `vault.d/lan5.enc` — so a copy always collided, landed as `lan5-2.enc`, and
216
+ * had its source deleted, leaving every item in it unreachable by any command.
217
+ */
218
+ export function migratesAcross(key: string): boolean {
219
+ return !key.startsWith('state.kv/') && !key.startsWith('vault.d/');
220
+ }
221
+
222
+ /**
223
+ * Two sealed documents have no union, so the command says so and stops.
224
+ *
225
+ * Deleting one silently is the outcome nobody would accept, and a copy under
226
+ * another name is one no command opens — so neither is offered. The refusal
227
+ * names the two ways forward, which is the rule every refusal here follows.
228
+ */
229
+ export function refuseSealedVault(profile: string, into: string): never {
230
+ throw new ConfigError(
231
+ `"${profile}" holds a sealed vault, and a vault cannot be merged into "${into}"'s: two ` +
232
+ 'encrypted documents have no union, and a copy under another name is one no command opens.\n' +
233
+ ` Move what you need first — lanes link vault list --profile ${profile} — then remove it ` +
234
+ 'with --delete-data.',
235
+ );
236
+ }