@lanes-sh/link 0.4.1 → 0.5.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 (42) hide show
  1. package/README.md +20 -9
  2. package/instructions/agents/lanes-link-scout.md +14 -3
  3. package/instructions/skills/lanes-link/SKILL.md +80 -3
  4. package/package.json +2 -2
  5. package/src/cli/argv.ts +7 -0
  6. package/src/cli/commands/connect/index.ts +9 -6
  7. package/src/cli/commands/connection.ts +298 -0
  8. package/src/cli/commands/operate/inspect.ts +37 -20
  9. package/src/cli/commands/operate/serve.ts +21 -0
  10. package/src/cli/commands/owner/assets.ts +132 -0
  11. package/src/cli/commands/owner/shared.ts +28 -4
  12. package/src/cli/commands/owner/tasks.ts +194 -0
  13. package/src/cli/commands/owner.ts +9 -4
  14. package/src/cli/config-edit.ts +33 -7
  15. package/src/cli/config-repair.ts +115 -11
  16. package/src/cli/dispatch-owner.ts +49 -8
  17. package/src/cli/lanes.ts +1 -1
  18. package/src/cli/main.ts +21 -2
  19. package/src/cli/provider-marks.ts +1 -1
  20. package/src/cli/runtime/registry.ts +10 -2
  21. package/src/cli/selection.ts +14 -0
  22. package/src/cli/usage.ts +18 -2
  23. package/src/connectivity/mail/attachments.ts +5 -1
  24. package/src/connectivity/mail/index.ts +6 -1
  25. package/src/connectivity/manifest/provider.ts +15 -2
  26. package/src/deployments/deploy.ts +3 -2
  27. package/src/deployments/prepare.ts +1 -1
  28. package/src/deployments/servable.ts +1 -1
  29. package/src/deployments/upload.ts +0 -53
  30. package/src/profile/load.ts +46 -0
  31. package/src/providers/assets/provider.ts +337 -0
  32. package/src/providers/assets/store.ts +167 -0
  33. package/src/providers/google/index.ts +1 -1
  34. package/src/providers/google/tasks/index.ts +3 -3
  35. package/src/providers/google/tasks/redact.ts +21 -11
  36. package/src/providers/index.ts +3 -3
  37. package/src/providers/owner.ts +39 -19
  38. package/src/providers/setup/plan.ts +17 -1
  39. package/src/providers/tasks/provider.ts +370 -0
  40. package/src/providers/tasks/store.ts +248 -0
  41. package/src/server/mcp/build.ts +1 -1
  42. package/src/server/mcp/instructions.ts +67 -8
@@ -1,4 +1,8 @@
1
1
  import {
2
+ assetsAdd,
3
+ assetsGet,
4
+ assetsList,
5
+ assetsRemove,
2
6
  memoryForget,
3
7
  memoryGet,
4
8
  memoryList,
@@ -11,18 +15,23 @@ import {
11
15
  vaultKeyGenerate,
12
16
  vaultList,
13
17
  vaultRemove,
18
+ tasksAdd,
19
+ tasksGet,
20
+ tasksList,
21
+ tasksRemove,
22
+ tasksUpdate,
14
23
  vaultSet,
15
24
  type OwnerFlags,
16
25
  } from './commands/owner.ts';
17
26
 
18
27
  /**
19
- * The three commands over the owner's own data: memory, skills, and the vault.
28
+ * The commands over the owner's own data: memory, tasks, assets, skills, vault.
20
29
  *
21
30
  * Split out of `main.ts` for the reason the budget in `src/architecture.test.ts`
22
- * exists to find, rather than to satisfy a line count. These three are one
23
- * subject — what the owner put here themselves, as against what a provider
24
- * holds on their behalf — they already live together in `commands/owner/`, and
25
- * they are the only commands in the grammar sharing a flag shape of their own.
31
+ * exists to find, rather than to satisfy a line count. These are one subject —
32
+ * what the owner put here themselves, as against what a provider holds on their
33
+ * behalf — they already live together in `commands/owner/`, and they are the
34
+ * only commands in the grammar sharing a flag shape of their own.
26
35
  *
27
36
  * `main.ts` keeps the grammar. This keeps one branch of it.
28
37
  */
@@ -49,6 +58,38 @@ export function dispatchOwner(
49
58
  throw new Error(`Unknown: ${program} memory ${second}`);
50
59
  }
51
60
 
61
+ case 'tasks':
62
+ switch (second) {
63
+ case 'list':
64
+ case undefined:
65
+ return tasksList(owner);
66
+ case 'get':
67
+ return tasksGet(rest[0], owner);
68
+ case 'add':
69
+ return tasksAdd(rest[0], owner);
70
+ case 'update':
71
+ return tasksUpdate(rest[0], owner);
72
+ case 'remove':
73
+ return tasksRemove(rest[0], owner);
74
+ default:
75
+ throw new Error(`Unknown: ${program} tasks ${second}`);
76
+ }
77
+
78
+ case 'assets':
79
+ switch (second) {
80
+ case 'list':
81
+ case undefined:
82
+ return assetsList(owner);
83
+ case 'get':
84
+ return assetsGet(rest[0], owner);
85
+ case 'add':
86
+ return assetsAdd(rest[0], owner);
87
+ case 'remove':
88
+ return assetsRemove(rest[0], owner);
89
+ default:
90
+ throw new Error(`Unknown: ${program} assets ${second}`);
91
+ }
92
+
52
93
  case 'skills':
53
94
  switch (second) {
54
95
  case 'list':
@@ -85,9 +126,9 @@ export function dispatchOwner(
85
126
  }
86
127
 
87
128
  default:
88
- // Unreachable: `main.ts` narrows to the three above before calling. Kept
89
- // so that adding a fourth case there and forgetting it here is a thrown
90
- // error rather than a command that silently does nothing.
129
+ // Unreachable: `main.ts` narrows to the nouns above before calling. Kept
130
+ // so that adding one there and forgetting it here is a thrown error rather
131
+ // than a command that silently does nothing.
91
132
  throw new Error(`Unknown: ${program} ${first}`);
92
133
  }
93
134
  }
package/src/cli/lanes.ts CHANGED
@@ -18,7 +18,7 @@ import { version } from './version.ts';
18
18
  */
19
19
 
20
20
  const AREAS: Record<string, string> = {
21
- link: 'a self-hostable MCP gateway for all your connections, memory, skills, and secrets',
21
+ link: 'a self-hostable MCP gateway for all your connections, memory, tasks, files, and secrets',
22
22
  };
23
23
 
24
24
  function areasUsage(): string {
package/src/cli/main.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { connect } from './commands/connect/index.ts';
2
2
  import { connectCustom } from './commands/connect/custom/index.ts';
3
+ import { disconnect, relabel } from './commands/connection.ts';
3
4
  import {
4
5
  attachFile,
5
6
  auditTail,
@@ -246,17 +247,20 @@ export async function run(argv: readonly string[]): Promise<void> {
246
247
  if (second !== 'show' && second !== undefined) throw new Error(`Unknown: ${PROGRAM} config ${second}`);
247
248
  return configShow(global);
248
249
 
249
- // memory, skills and vault — one subject, dispatched together.
250
+ // The owner's own data — one subject, dispatched together.
250
251
  // `vault key generate` is synchronous, so this returns the result rather
251
252
  // than testing it for truthiness.
252
253
  case 'memory':
254
+ case 'tasks':
255
+ case 'assets':
253
256
  case 'skills':
254
257
  case 'vault':
255
258
  return dispatchOwner(first, second, rest, owner, PROGRAM);
256
259
 
257
260
  // Beside `memory` and `skills` because it is the question they raise next:
258
261
  // those two say what is stored, and this says where it is kept. Not one of
259
- // them, though — it takes its own flags rather than the owner set.
262
+ // them, though — it takes its own flags rather than the owner set, and it
263
+ // moves those two only (ADR-041), not tasks or assets.
260
264
  case 'knowledge':
261
265
  switch (second) {
262
266
  case 'show':
@@ -329,6 +333,21 @@ export async function run(argv: readonly string[]): Promise<void> {
329
333
  default:
330
334
  throw new Error(`Unknown: ${PROGRAM} mcp ${second}`);
331
335
  }
336
+ case 'disconnect':
337
+ return disconnect(second, {
338
+ ...global,
339
+ yes: flags['yes'] === true,
340
+ keepCredential: flags['keep-credential'] === true,
341
+ json: flags['json'] === true,
342
+ });
343
+ // The new label is joined rather than taken as `rest[0]`, so an unquoted
344
+ // multi-word name works: `relabel gmail.main Work Mail` is what someone
345
+ // types before they think about quoting, and refusing it teaches nothing.
346
+ case 'relabel':
347
+ return relabel(second, rest.length > 0 ? rest.join(' ') : undefined, {
348
+ ...global,
349
+ json: flags['json'] === true,
350
+ });
332
351
  case 'start':
333
352
  return start({
334
353
  ...global,
@@ -41,5 +41,5 @@ export const PROVIDER_MARKS: Readonly<Record<string, string>> = {
41
41
  linear: 'M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z',
42
42
  notion: 'M4.459 4.208c.746.606 1.026.56 2.428.466l13.215-.793c.28 0 .047-.28-.046-.326L17.86 1.968c-.42-.326-.981-.7-2.055-.607L3.01 2.295c-.466.046-.56.28-.374.466zm.793 3.08v13.904c0 .747.373 1.027 1.214.98l14.523-.84c.841-.046.935-.56.935-1.167V6.354c0-.606-.233-.933-.748-.887l-15.177.887c-.56.047-.747.327-.747.933zm14.337.745c.093.42 0 .84-.42.888l-.7.14v10.264c-.608.327-1.168.514-1.635.514-.748 0-.935-.234-1.495-.933l-4.577-7.186v6.952L12.21 19s0 .84-1.168.84l-3.222.186c-.093-.186 0-.653.327-.746l.84-.233V9.854L7.822 9.76c-.094-.42.14-1.026.793-1.073l3.456-.233 4.764 7.279v-6.44l-1.215-.139c-.093-.514.28-.887.747-.933zM1.936 1.035l13.31-.98c1.634-.14 2.055-.047 3.082.7l4.249 2.986c.7.513.934.653.934 1.213v16.378c0 1.026-.373 1.634-1.68 1.726l-15.458.934c-.98.047-1.448-.093-1.962-.747l-3.129-4.06c-.56-.747-.793-1.306-.793-1.96V2.667c0-.839.374-1.54 1.447-1.632z',
43
43
  sheets: 'M11.318 12.545H7.91v-1.909h3.41v1.91zM14.728 0v6h6l-6-6zm1.363 10.636h-3.41v1.91h3.41v-1.91zm0 3.273h-3.41v1.91h3.41v-1.91zM20.727 6.5v15.864c0 .904-.732 1.636-1.636 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.318v6.5h6.5zm-3.273 2.773H6.545v7.909h10.91v-7.91zm-6.136 4.636H7.91v1.91h3.41v-1.91z',
44
- tasks: 'M11.383.617C5.097.617 0 5.714 0 12c0 6.286 5.097 11.383 11.383 11.383 6.286 0 11.38-5.097 11.38-11.383a11.34 11.34 0 0 0-.878-4.389l-3.203 3.203c.062.387.1.782.1 1.186a7.398 7.398 0 1 1-7.4-7.398c1.499 0 2.889.448 4.054 1.214l2.857-2.857a11.325 11.325 0 0 0-6.91-2.342zm9.674.756c-.292 0-.583.112-.805.334-2.97 2.965-5.934 5.934-8.9 8.902L9.596 8.854a1.139 1.139 0 0 0-1.61 0l-1.775 1.773a1.139 1.139 0 0 0 0 1.61l4.166 4.163a1.421 1.421 0 0 0 2.012 0L23.666 5.121a1.136 1.136 0 0 0 0-1.61l-1.805-1.804a1.136 1.136 0 0 0-.804-.334z',
44
+ google_tasks: 'M11.383.617C5.097.617 0 5.714 0 12c0 6.286 5.097 11.383 11.383 11.383 6.286 0 11.38-5.097 11.38-11.383a11.34 11.34 0 0 0-.878-4.389l-3.203 3.203c.062.387.1.782.1 1.186a7.398 7.398 0 1 1-7.4-7.398c1.499 0 2.889.448 4.054 1.214l2.857-2.857a11.325 11.325 0 0 0-6.91-2.342zm9.674.756c-.292 0-.583.112-.805.334-2.97 2.965-5.934 5.934-8.9 8.902L9.596 8.854a1.139 1.139 0 0 0-1.61 0l-1.775 1.773a1.139 1.139 0 0 0 0 1.61l4.166 4.163a1.421 1.421 0 0 0 2.012 0L23.666 5.121a1.136 1.136 0 0 0 0-1.61l-1.805-1.804a1.136 1.136 0 0 0-.804-.334z',
45
45
  };
@@ -7,12 +7,14 @@ import { loadProfileProviders } from '#providers/custom/index.ts';
7
7
  import { loadProfileSkills, type LoadedSkill } from '#providers/skills/store.ts';
8
8
  import { exampleProvider } from '#providers/example/provider.ts';
9
9
  import {
10
+ assetsProvider,
10
11
  createIdentityProvider,
11
12
  createMemoryVaultStore,
12
13
  createSetupProvider,
13
14
  createSkillsProvider,
14
15
  createVaultProvider,
15
16
  memoryProvider,
17
+ tasksProvider,
16
18
  type IdentityProviderOptions,
17
19
  type SetupProviderOptions,
18
20
  type VaultStore,
@@ -84,8 +86,8 @@ export interface OwnerLayerOptions {
84
86
  * Statically imported for now; the registry does not care where a manifest came
85
87
  * from, which is what lets workspace YAML register alongside these.
86
88
  *
87
- * `allowReserved` is what admits `memory`, `skills`, `vault`, `setup`, and
88
- * `identity`. The guard
89
+ * `allowReserved` is what admits `memory`, `tasks`, `assets`, `skills`, `vault`,
90
+ * `setup`, and `identity`. The guard
89
91
  * stays rather than being retired: it exists so a *third-party* provider cannot
90
92
  * claim a namespace whose policy rules would then silently mean something else,
91
93
  * and that reason survives the owner layer shipping. Only this one construction
@@ -95,7 +97,13 @@ export function buildRegistry(owner: OwnerLayerOptions = {}): ProviderRegistry {
95
97
  const registry = new ProviderRegistry({ allowReserved: true });
96
98
 
97
99
  registry.register(exampleProvider);
100
+ // The three that hold what the owner keeps. All module-level constants rather
101
+ // than factories: each reads and writes through `context.storage`, which core
102
+ // scopes per call, so there is nothing to hand in at construction time and
103
+ // nothing for `OwnerLayerOptions` to carry.
98
104
  registry.register(memoryProvider);
105
+ registry.register(tasksProvider);
106
+ registry.register(assetsProvider);
99
107
  registry.register(skillsProviderFor(owner));
100
108
  registry.register(
101
109
  createVaultProvider({
@@ -111,6 +111,11 @@ export const SELECTION: Record<string, Requires> = {
111
111
  'identity list': 'profile',
112
112
 
113
113
  connect: 'profile+target',
114
+ // Both edit the profile config, and `disconnect` also opens the target's
115
+ // credential store to delete from it. Same requirement as `connect` for the
116
+ // same reasons.
117
+ disconnect: 'profile+target',
118
+ relabel: 'profile+target',
114
119
  // Its own row rather than an inheritance from `connect`. Both need the same
115
120
  // two things, but the row is what makes `selectionKey` return the two-word
116
121
  // key — and that is what keeps thirty declaration flags off
@@ -153,6 +158,8 @@ export const SELECTION: Record<string, Requires> = {
153
158
  'mcp add': 'profile+target',
154
159
  'mcp stdio': 'profile+target',
155
160
  memory: 'profile+target',
161
+ tasks: 'profile+target',
162
+ assets: 'profile+target',
156
163
  skills: 'profile+target',
157
164
  vault: 'profile+target',
158
165
  // Both halves open the target's adapters — `show` counts what is in the
@@ -181,6 +188,8 @@ const SUBCOMMANDS: Record<string, readonly string[]> = {
181
188
  config: ['show'],
182
189
  setup: ['plan'],
183
190
  memory: ['list', 'get', 'write', 'forget'],
191
+ tasks: ['list', 'get', 'add', 'update', 'remove'],
192
+ assets: ['list', 'get', 'add', 'remove'],
184
193
  skills: ['list', 'show', 'add', 'remove'],
185
194
  vault: ['list', 'get', 'set', 'remove', 'key'],
186
195
  mcp: ['skill', 'add', 'stdio', 'list'],
@@ -310,6 +319,8 @@ const ACCEPTS: Record<string, readonly string[]> = {
310
319
  // `removalPlan`, and was refused here — the flag existed everywhere except in
311
320
  // the list that decides whether it may be typed.
312
321
  'profile remove': ['dry-run', 'yes', 'target'],
322
+ disconnect: ['yes', 'keep-credential'],
323
+ relabel: [],
313
324
  'target list': ['urls', 'target'],
314
325
  'target show': ['target'],
315
326
  'token show': ['show', 'raw'],
@@ -332,6 +343,9 @@ const ACCEPTS: Record<string, readonly string[]> = {
332
343
  update: ['check'],
333
344
  'identity add': ['note'],
334
345
  memory: ['connection', 'title', 'description', 'file', 'tag'],
346
+ // `--yes` on both, because both have a delete that asks first.
347
+ tasks: ['connection', 'title', 'status', 'due', 'tag', 'yes'],
348
+ assets: ['connection', 'name', 'content-type', 'yes'],
335
349
  skills: ['connection', 'title', 'description', 'file'],
336
350
  vault: ['connection'],
337
351
  // `no-migrate` is listed beside `migrate` because they are three states
package/src/cli/usage.ts CHANGED
@@ -16,7 +16,7 @@ import { style } from './output.ts';
16
16
  /** How this CLI is invoked — the `link` area of the `lanes` command. */
17
17
  export const PROGRAM = 'lanes link';
18
18
 
19
- export const USAGE = `${style.bold(PROGRAM)} — a self-hostable MCP gateway for all your connections, memory, skills, and secrets
19
+ export const USAGE = `${style.bold(PROGRAM)} — a self-hostable MCP gateway for all your connections, memory, tasks, files, and secrets
20
20
 
21
21
  ${style.bold('Everyday')}
22
22
  ${PROGRAM} setup plan [--json] what each provider needs, and which are connected
@@ -26,6 +26,9 @@ ${style.bold('Everyday')}
26
26
  ${PROGRAM} connect <...> --replace ask for the stored password or key again
27
27
  ${PROGRAM} connect <...> --auth <method> pick how, where there is a choice
28
28
  ${PROGRAM} connect <...> --non-interactive [--json]
29
+ ${PROGRAM} disconnect <provider>.<id> remove an account, and delete its credential
30
+ ${PROGRAM} disconnect <...> --keep-credential leave the credential in the store
31
+ ${PROGRAM} relabel <provider>.<id> <name> rename what an account is called
29
32
  ${PROGRAM} connect custom <id> --connector <kind> --auth <method>
30
33
  declare a service that is not built in, and connect it.
31
34
  kinds: mcp, http, imap, dav, fs. Omit a value and it is
@@ -78,6 +81,18 @@ ${style.bold('Your own context')}
78
81
  ${PROGRAM} memory write <id> --title <t> [--tag t] body on stdin
79
82
  ${PROGRAM} memory forget <id>
80
83
 
84
+ ${PROGRAM} tasks list [--status s] what is outstanding; --status all for everything
85
+ ${PROGRAM} tasks get <id>
86
+ ${PROGRAM} tasks add <title> [--status s] [--due d] [--tag t] notes on stdin
87
+ ${PROGRAM} tasks update <id> --status <s> closing one is an update, not a remove
88
+ ${PROGRAM} tasks remove <id>
89
+ statuses: in_progress open blocked muted done dropped
90
+
91
+ ${PROGRAM} assets list files kept in this profile
92
+ ${PROGRAM} assets get <name> the bytes, to stdout — redirect them
93
+ ${PROGRAM} assets add <file> [--name n] [--content-type t]
94
+ ${PROGRAM} assets remove <name>
95
+
81
96
  ${PROGRAM} skills list the procedures agents can invoke
82
97
  ${PROGRAM} skills show <name>
83
98
  ${PROGRAM} skills add <name> [--file f] document on stdin
@@ -131,7 +146,8 @@ ${style.bold('Naming what a command acts on')}
131
146
  command that names neither refuses and lists what exists.
132
147
 
133
148
  ${style.bold('Other flags')}
134
- --connection <id> which memory/skills/vault connection, if a profile has several
149
+ --connection <id> which memory/tasks/assets/skills/vault connection, where
150
+ a profile has several of one kind
135
151
  --yes skip the confirmation a destructive command would ask for
136
152
  --json machine-readable output, where a command offers it
137
153
  --non-interactive never prompt: connect refuses with what to store,
@@ -362,7 +362,11 @@ const CONTENT_TYPES: Readonly<Record<string, string>> = {
362
362
  // opens by extension anyway. Adding them back will fail the suite.
363
363
  };
364
364
 
365
- function guessContentType(filename: string): string {
365
+ /**
366
+ * Exported because `assets` needs exactly this and the table above must not be
367
+ * copied — its `.pages`/`.numbers` note is a rule the copy would not carry.
368
+ */
369
+ export function guessContentType(filename: string): string {
366
370
  const extension = filename.split('.').pop()?.toLowerCase();
367
371
  return (extension ? CONTENT_TYPES[extension] : undefined) ?? 'application/octet-stream';
368
372
  }
@@ -21,7 +21,12 @@ export { receiptFor } from './message.ts';
21
21
  export { composeMime } from './compose.ts';
22
22
 
23
23
  export type { AttachmentRef, MailboxAttachmentSource, ResolveOptions } from './attachments.ts';
24
- export { attachmentRefSchema, attachmentsJsonSchema, resolveAttachments } from './attachments.ts';
24
+ export {
25
+ attachmentRefSchema,
26
+ attachmentsJsonSchema,
27
+ guessContentType,
28
+ resolveAttachments,
29
+ } from './attachments.ts';
25
30
 
26
31
  export type { StagedFile, StagedMetadata } from './staging.ts';
27
32
  export {
@@ -64,8 +64,21 @@ export const providerManifestSchema = z.object({
64
64
 
65
65
  export type ProviderManifest = z.infer<typeof providerManifestSchema>;
66
66
 
67
- /** Provider ids reserved for the owner layer. */
68
- export const RESERVED_PROVIDER_IDS: readonly string[] = ['memory', 'skills', 'vault', 'setup', 'identity'];
67
+ /**
68
+ * Provider ids reserved for the owner layer.
69
+ *
70
+ * The order is read: `#server/mcp`'s instructions emit one paragraph per
71
+ * reachable id in this sequence, so it is the order an agent meets them in.
72
+ */
73
+ export const RESERVED_PROVIDER_IDS: readonly string[] = [
74
+ 'memory',
75
+ 'tasks',
76
+ 'assets',
77
+ 'skills',
78
+ 'vault',
79
+ 'setup',
80
+ 'identity',
81
+ ];
69
82
 
70
83
  /**
71
84
  * Validate a manifest, with the cross-field rules the schema alone cannot
@@ -7,7 +7,8 @@ import { resolveTarget, vaultEnv } from './bootstrap.ts';
7
7
  import { printSteps, runSteps } from './steps.ts';
8
8
  import { driverFor } from './drivers.ts';
9
9
  import { prepareSecrets, readableRefs, rotatableRefs } from './prepare.ts';
10
- import { deployedWorkspace, repairSetupSurface, uploadWorkspace } from './upload.ts';
10
+ import { repairOwnerLayer } from '#cli/config-repair.ts';
11
+ import { deployedWorkspace, uploadWorkspace } from './upload.ts';
11
12
  import { unservableProfiles, unservableRefusal } from './servable.ts';
12
13
  import { collidingRefs, collisionRefusal, servingProfiles } from './serving.ts';
13
14
  import { healthLine, reachability, registerLine, reportUnauthorised } from './report.ts';
@@ -255,7 +256,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
255
256
  throw new ConfigError(unservableRefusal(unservable, target));
256
257
  }
257
258
 
258
- await repairSetupSurface(resolution.workspaceRoot, serving);
259
+ await repairOwnerLayer(resolution.workspaceRoot, serving);
259
260
 
260
261
  // Before the rollout, so the revision that comes up finds a config to read.
261
262
  // Uploading after would leave a window where the service is serving and the
@@ -46,7 +46,7 @@ export interface PrepareResult {
46
46
  * path derives from the manifest and has never read config for. Sharing one list
47
47
  * would silently pick one answer for both.
48
48
  *
49
- * **Scoped exactly as the upload is**, for the reason `repairSetupSurface`
49
+ * **Scoped exactly as the upload is**, for the reason `repairOwnerLayer`
50
50
  * states and one more: a profile this deploy sends is a profile the endpoint may
51
51
  * serve, and a connection whose secret nobody bound fails an hour after the
52
52
  * revision reports healthy. The asymmetry decides it — an extra binding is a
@@ -30,7 +30,7 @@ export interface Unservable {
30
30
  /**
31
31
  * The profiles this deploy would send that the revision could not open.
32
32
  *
33
- * Scoped exactly as `uploadWorkspace` and `repairSetupSurface` are — by the
33
+ * Scoped exactly as `uploadWorkspace` and `repairOwnerLayer` are — by the
34
34
  * `--profile` flag, absent meaning the whole workspace — because the set that
35
35
  * gets uploaded is the set that gets served, and checking a different one would
36
36
  * be checking the wrong question.
@@ -7,8 +7,6 @@ import {
7
7
  type Config,
8
8
  type TargetConfig,
9
9
  } from '#profile';
10
- import { ConfigDocument } from '#cli/config-edit.ts';
11
- import { ensureSetupConnection, repairLines, repaired } from '#cli/config-repair.ts';
12
10
  import { ok, print, style, warn } from '#cli/output.ts';
13
11
 
14
12
  /**
@@ -100,57 +98,6 @@ function authoredAreaOwner(key: string): string | null {
100
98
  return area === layout.skills(owner) || area === layout.providers(owner) ? owner : null;
101
99
  }
102
100
 
103
- /**
104
- * Give every profile about to be uploaded its setup surface.
105
- *
106
- * **Scoped exactly as the upload is**, because a profile this deploy sends is a
107
- * profile the endpoint will serve: repairing a narrower set would leave a served
108
- * profile without the surface, which is this bug one profile over. Note what
109
- * `flags.profile` does not mean — it is the flag alone, so a profile resolved
110
- * from `LANES_LINK_PROFILE` leaves it undefined and both this and the upload
111
- * read that as the whole workspace. Surprising, pre-existing in the upload, and
112
- * fixed there rather than here so the two cannot drift apart.
113
- *
114
- * *Which files are profiles* comes from `listProfiles`, never from
115
- * `isWorkspaceConfig`: the allowlist decides what is safe to *copy*, so it
116
- * happily sends a committed `personal.example.yaml` and a nested
117
- * `profiles/archive/old.yaml` as bytes, while this opens and validates what it
118
- * is handed — which turned that template into a `ConfigError` aborting the
119
- * deploy after provisioning had already made cloud resources.
120
- *
121
- * A profile that cannot be read is warned about rather than fatal: the repair is
122
- * a courtesy on the way past, and the upload still sends the file. Not silent,
123
- * though — nothing else here widens a policy without being asked.
124
- */
125
- export async function repairSetupSurface(
126
- workspaceRoot: string,
127
- profiles: readonly string[] | undefined,
128
- ): Promise<void> {
129
- const wanted = profiles === undefined ? undefined : new Set(profiles);
130
-
131
- for (const name of await listProfiles(workspaceRoot)) {
132
- if (wanted !== undefined && !wanted.has(name)) continue;
133
-
134
- try {
135
- const document = await ConfigDocument.open(workspaceRoot, name);
136
- const repair = ensureSetupConnection(document);
137
- if (!repaired(repair)) continue;
138
-
139
- await document.save();
140
-
141
- print(ok(`gave ${style.bold(name)} the setup surface`));
142
- for (const change of repairLines(repair)) print(` ${style.dim(change)}`);
143
- print(` ${style.dim('an agent can now see what is connected here, instead of guessing')}`);
144
- } catch (error) {
145
- print(
146
- warn(
147
- `could not give ${name} the setup surface: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`,
148
- ),
149
- );
150
- }
151
- }
152
- }
153
-
154
101
  /**
155
102
  * Copy the workspace's config up.
156
103
  */
@@ -107,6 +107,49 @@ function formatZodIssues(error: z.ZodError): string {
107
107
  * left to resolve silently it grants nothing, which looks identical to a
108
108
  * working rule until the day someone relies on it.
109
109
  */
110
+ /**
111
+ * A connection naming a provider whose id has moved out from under it.
112
+ *
113
+ * There is exactly one, and it is the reason this function exists: `tasks` was
114
+ * Google Tasks until the built-in task list took the plain noun (ADR-051). A row
115
+ * left saying `provider: tasks` does not fail — it resolves to the *built-in*,
116
+ * `reconcile` marks it active because a provider needing no credential is
117
+ * authorized by construction, and the operator is left with their Google Tasks
118
+ * tools gone, a task list wearing their old label, and nothing anywhere saying
119
+ * why. Refusing is the only outcome that names the fix.
120
+ *
121
+ * **The rule is a positive assertion, not a guess at what a vendor row looks
122
+ * like.** The built-in's row is written in exactly one spelling, by
123
+ * `newProfileTemplate` and by `ensureReservedConnection`: `account: Tasks`. So
124
+ * any other label on a `tasks` row is either a pre-rename Google Tasks row or a
125
+ * hand-edited built-in one, and the message names both fixes because either is
126
+ * one word.
127
+ *
128
+ * It was very nearly a guess, and the guess was wrong. The first version keyed
129
+ * on an `@` in the account, reasoning that `connect tasks` recorded the address
130
+ * the operator typed — Google Tasks publishes no identity, so `connect` asks.
131
+ * But what it asks for is a *label*: the real profile this was written for holds
132
+ * `account: personal`, so the check would have passed it and rebound their Google
133
+ * Tasks to the built-in in silence. That is the exact failure this exists to
134
+ * prevent, missed by one heuristic.
135
+ *
136
+ * Deliberately not a check on the connection *id*. Several task lists in one
137
+ * profile is a legitimate thing to want, exactly as several memory connections
138
+ * are, and keying on `id !== 'main'` would refuse a valid profile forever to
139
+ * catch a one-release migration. Labelling both `Tasks` is consistent with what
140
+ * the accountless providers already do — every memory connection is `Memory`.
141
+ */
142
+ function renamedProvider(connection: { provider: string; account: string }): string | null {
143
+ if (connection.provider !== 'tasks' || connection.account === 'Tasks') return null;
144
+
145
+ return (
146
+ `"tasks" is now the built-in task list, and this row is labelled ` +
147
+ `"${connection.account}" rather than "Tasks".\n` +
148
+ ' If it was Google Tasks: set provider to google_tasks here, and rename any "tasks.*" policy rule.\n' +
149
+ ' If it is your own task list: set account to Tasks.'
150
+ );
151
+ }
152
+
110
153
  function assertReferentialIntegrity(config: Config, source: string): void {
111
154
  const problems: string[] = [];
112
155
 
@@ -142,6 +185,9 @@ function assertReferentialIntegrity(config: Config, source: string): void {
142
185
  problems.push(`connections[${index}]: duplicate connection "${key}"`);
143
186
  }
144
187
  connectionKeys.add(key);
188
+
189
+ const renamed = renamedProvider(connection);
190
+ if (renamed) problems.push(`connections[${index}]: ${renamed}`);
145
191
  });
146
192
 
147
193
  // Same reason as a duplicate connection: two entries with the same kind and