@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,38 +1,58 @@
1
1
  /**
2
- * The owner layer — memory, skills, vault, setup, identity.
2
+ * The owner layer — memory, tasks, assets, skills, vault, setup, identity.
3
3
  *
4
- * Three providers that hold no third-party account: no OAuth, no vendor API, no
4
+ * Seven providers that hold no third-party account: no OAuth, no vendor API, no
5
5
  * rate limit anyone else imposes. They are ordinary `defineLocalProvider`
6
- * registrations, configured by the same `lanes link connect <provider>`, scoped by the
7
- * same profiles, and gated by the same policy evaluation as everything else —
8
- * which was the claim `docs/detailed/init.md` made and called the real test of the
9
- * architecture.
6
+ * registrations, scoped by the same profiles and gated by the same policy
7
+ * evaluation as everything else which was the claim `docs/detailed/init.md`
8
+ * made and called the real test of the architecture. Since ADR-050 they are also
9
+ * the ones a fresh profile arrives with already granted, because what they reach
10
+ * is the owner's own material and there is no account behind them to protect.
10
11
  *
11
- * They live in `./memory/`, `./skills/` and `./vault/`, beside `google/` and
12
- * `icloud/`, because that claim is only true if they are providers in the
13
- * layout as well as in the prose. This file is the one thing they share: a
14
- * barrel, because each is *constructed* with a store rather than declared as
15
- * data, so `#profile`'s registry builder needs all three together.
12
+ * Each lives in its own folder beside `google/` and `icloud/`, because that
13
+ * claim is only true if they are providers in the layout as well as in the
14
+ * prose. This file is the one thing they share: a barrel, because several are
15
+ * *constructed* with a store rather than declared as data, so the registry
16
+ * builder needs them together.
16
17
  *
17
- * `setup` joined them later and is the same shape: it holds no account either,
18
- * and it describes the other three rather than reaching anything. It is
19
- * read-only by construction see ADR-019 for why describing setup is not one
18
+ * `memory`, `tasks` and `assets` are the three that hold what the owner keeps,
19
+ * and they divide by what a thing *is* rather than by size: memory is what is
20
+ * true, tasks is what is to be done, assets is a file. That split is the whole
21
+ * of ADR-051, and the routing rule an agent needs is stated in
22
+ * `#server/mcp`'s instructions and in the bundled skill.
23
+ *
24
+ * `setup` is the same shape and holds no account either, but it describes the
25
+ * others rather than holding anything of the owner's. It is read-only by
26
+ * construction — see ADR-019 for why describing setup is not one
20
27
  * of ADR-007's control-plane exclusions.
21
28
  *
22
- * `identity` is the fifth and holds no account either. It says who the owner is
29
+ * `identity` holds no account either. It says who the owner is
23
30
  * — the names and addresses to write as them — and is read-only for the reason
24
31
  * `setup` is: what it reports is configuration, and configuration is changed in
25
32
  * the CLI. It is a provider of its own rather than a section of `setup` so that
26
33
  * naming the owner and describing what is connected are two policy decisions
27
34
  * instead of one.
28
35
  *
29
- * The ids `memory`, `skills`, `vault`, `setup`, and `identity` are reserved (`RESERVED_PROVIDER_IDS`)
30
- * and still refused by default — the registry has to be built with
31
- * `allowReserved` to hold them, so a third-party provider cannot claim a
32
- * namespace whose policy rules would then mean something else.
36
+ * All seven ids are reserved (`RESERVED_PROVIDER_IDS`) and still refused by
37
+ * default — the registry has to be built with `allowReserved` to hold them, so a
38
+ * third-party provider cannot claim a namespace whose policy rules would then
39
+ * mean something else. `tasks` cost something to reserve: Google Tasks held that
40
+ * id and was renamed `google_tasks`, because the plain noun belongs to the
41
+ * owner's own list and a manifest already registered under it would have thrown
42
+ * on the second registration rather than shadowing anything.
33
43
  */
34
44
 
35
45
  export { memoryProvider, memoryStorage, assertEntryId, type MemoryEntry } from './memory/provider.ts';
46
+ export { tasksProvider } from './tasks/provider.ts';
47
+ export {
48
+ ACTIVE_STATUSES,
49
+ TASK_STATUSES,
50
+ taskStorage,
51
+ type Task,
52
+ type TaskStatus,
53
+ } from './tasks/store.ts';
54
+ export { assetsProvider } from './assets/provider.ts';
55
+ export { assetStorage, type Asset } from './assets/store.ts';
36
56
  export { createSkillsProvider, type SkillsProviderOptions } from './skills/provider.ts';
37
57
  export { createVaultProvider, type VaultProviderOptions } from './vault/provider.ts';
38
58
  export { createSetupProvider, type SetupProviderOptions } from './setup/provider.ts';
@@ -1,5 +1,5 @@
1
1
  import type { ProviderManifest } from '#connectivity';
2
- import { hasOwnClientPath, setupRequirements, type SetupRequirement } from '#connectivity';
2
+ import { hasOwnClientPath, RESERVED_PROVIDER_IDS, setupRequirements, type SetupRequirement } from '#connectivity';
3
3
 
4
4
  /**
5
5
  * What connecting a provider involves, assembled from its manifest.
@@ -19,6 +19,21 @@ export interface ProviderPlan {
19
19
  readonly id: string;
20
20
  readonly name: string;
21
21
  readonly description: string;
22
+ /**
23
+ * This provider is part of what a profile *is*, not an account it holds.
24
+ *
25
+ * The owner layer — `memory`, `skills`, `vault`, `setup`, `identity` — keyed off
26
+ * `RESERVED_PROVIDER_IDS`, which is the same list the registry uses to stop a
27
+ * third-party manifest claiming one of those ids.
28
+ *
29
+ * Reported because it is the fact a surface needs and cannot derive.
30
+ * `multiAccount` is the nearest thing and it is not it: that is a credential
31
+ * test, and `icloud_drive` is `auth: none` without being owner-layer at all. A
32
+ * client wanting to group these, or to withhold a disconnect that would leave a
33
+ * dangling policy grant, was left hardcoding the list — which then goes stale
34
+ * the next time one is added here. This travels with the release instead.
35
+ */
36
+ readonly reserved: boolean;
22
37
  /** Connection keys of this provider already configured, e.g. `gmail.main`. */
23
38
  readonly connected: readonly string[];
24
39
  /**
@@ -122,6 +137,7 @@ export function planFor(
122
137
  id: manifest.id,
123
138
  name: manifest.name,
124
139
  description: manifest.description,
140
+ reserved: RESERVED_PROVIDER_IDS.includes(manifest.id),
125
141
  connected,
126
142
  multiAccount: manifest.auth.kind !== 'none',
127
143
  browser: manifest.auth.kind === 'oauth',
@@ -0,0 +1,370 @@
1
+ import { z } from 'zod';
2
+ import { defineLocalProvider, keepKeys, type ProviderDefinition } from '#connectivity';
3
+ import {
4
+ ACTIVE_STATUSES,
5
+ TASK_STATUSES,
6
+ allTasks,
7
+ assertTaskId,
8
+ readTask,
9
+ slugify,
10
+ taskKey,
11
+ writeTask,
12
+ type Task,
13
+ type TaskStatus,
14
+ } from './store.ts';
15
+
16
+ /**
17
+ * `tasks` — what the owner has to do.
18
+ *
19
+ * **This exists because memory was being used for it.** "Remember to chase the
20
+ * invoice" was landing in `memory.write`, and memory has no way to express that
21
+ * something is finished: an entry is a fact, facts do not close, and nothing
22
+ * ever removed it. A task carries a status, which is the whole difference, and
23
+ * the routing rule is stated where an agent reads it — the paragraph in
24
+ * `#server/mcp`'s instructions and the bundled skill both say that a thing to
25
+ * *do* goes here and a thing that is merely *true* goes in memory. ADR-051.
26
+ *
27
+ * Everything else is memory's design, deliberately unchanged: one Markdown file
28
+ * per task in the `BlobStore` core scoped to `tasks/<connection>`, reading and
29
+ * writing as separate capabilities, no index. The format lives in `./store.ts`.
30
+ *
31
+ * **Reading and writing are separate**, for the reason ADR-012 §2 gives for
32
+ * memory: text an agent authors is stored once and re-served to every later
33
+ * session, including to a different agent. A task list is a smaller version of
34
+ * that risk rather than a different one — an injected "task" is an instruction
35
+ * with a due date — so the split is the same and so is the one-line narrowing,
36
+ * `deny: [tasks.add, tasks.update, tasks.remove]`.
37
+ */
38
+
39
+ const DEFAULT_LIMIT = 20;
40
+
41
+ const statusSchema = z.enum(TASK_STATUSES);
42
+
43
+ /** `in_progress · chase the invoice (due 2026-09-01) [billing]` */
44
+ function line(task: Task): string {
45
+ const tags = task.tags.length > 0 ? ` [${task.tags.join(', ')}]` : '';
46
+ const due = task.due ? ` (due ${task.due})` : '';
47
+ return `${task.status} · ${task.title}${due}${tags}`;
48
+ }
49
+
50
+ /** Whether a task matches a free-text query, over the parts a person would search. */
51
+ function matches(task: Task, needle: string): boolean {
52
+ return (
53
+ task.title.toLowerCase().includes(needle) ||
54
+ task.body.toLowerCase().includes(needle) ||
55
+ task.tags.some((tag) => tag.toLowerCase().includes(needle))
56
+ );
57
+ }
58
+
59
+ export const tasksProvider: ProviderDefinition = defineLocalProvider({
60
+ id: 'tasks',
61
+ name: 'Tasks',
62
+ version: '1.0.0',
63
+ description:
64
+ "What the owner has to do. A task carries a status, which is why this is not memory: use it for anything to be done, and memory for what is merely true. Writing is a separate capability from reading.",
65
+
66
+ configSchema: z.object({}),
67
+ connectionSchema: z.object({}),
68
+
69
+ bundles: [
70
+ {
71
+ name: 'read',
72
+ description: 'List and read tasks.',
73
+ oauth_scopes: [],
74
+ capabilities: ['task', 'list', 'get'],
75
+ default: true,
76
+ },
77
+ {
78
+ name: 'write',
79
+ description: 'Add tasks, change their status, and delete them.',
80
+ oauth_scopes: [],
81
+ capabilities: ['add', 'update', 'remove'],
82
+ },
83
+ ],
84
+
85
+ capabilities: [
86
+ /**
87
+ * Retrieval by address — a resource, not a tool, on ADR-006's rule: the
88
+ * answer is a function of the URI alone. The same case memory's `entry` is.
89
+ */
90
+ {
91
+ kind: 'resource',
92
+ name: 'task',
93
+ title: 'Task',
94
+ description: 'One task, addressed by its id.',
95
+ uriTemplate: 'tasks://task/{id}',
96
+ mimeType: 'text/markdown',
97
+ redact: keepKeys('uri'),
98
+
99
+ async list(context) {
100
+ return (await allTasks(context.storage)).map((task) => ({
101
+ uri: `tasks://task/${encodeURIComponent(task.id)}`,
102
+ name: task.title,
103
+ }));
104
+ },
105
+
106
+ async read(uri, params, context) {
107
+ const raw = params['id'];
108
+ if (!raw) throw new Error(`Malformed task URI: ${uri}`);
109
+
110
+ const id = decodeURIComponent(raw);
111
+ const task = await readTask(context.storage, id);
112
+ if (task === null) throw new Error(`No task "${id}" on ${context.connection.key}`);
113
+
114
+ return { uri, mimeType: 'text/markdown', text: describe(task) };
115
+ },
116
+ },
117
+
118
+ {
119
+ kind: 'tool',
120
+ name: 'list',
121
+ title: 'List tasks',
122
+ description:
123
+ 'Tasks the owner has open. Shows in_progress, open and blocked by default — finished, dropped and muted work is excluded unless you name a status, because the question is almost always what is outstanding. Ordered by status, then due date.',
124
+ inputSchema: z.object({
125
+ status: z
126
+ .array(statusSchema)
127
+ .optional()
128
+ .describe('Statuses to include. Defaults to in_progress, open and blocked.'),
129
+ tag: z.string().optional().describe('Restrict to tasks carrying this tag'),
130
+ query: z.string().optional().describe('Free text to look for in the title, notes, or tags'),
131
+ limit: z
132
+ .number()
133
+ .int()
134
+ .min(1)
135
+ .max(200)
136
+ .optional()
137
+ .describe(`Maximum results (default ${DEFAULT_LIMIT})`),
138
+ }),
139
+ // Nothing kept. A task query is as revealing as a memory search: it is
140
+ // the owner's own material being asked for by name.
141
+ async handler({ status, tag, query, limit }, context) {
142
+ const wanted = new Set<TaskStatus>(status ?? ACTIVE_STATUSES);
143
+ const needle = query?.toLowerCase();
144
+
145
+ const all = await allTasks(context.storage);
146
+ const found = all.filter(
147
+ (task) =>
148
+ wanted.has(task.status) &&
149
+ (!tag || task.tags.includes(tag)) &&
150
+ (!needle || matches(task, needle)),
151
+ );
152
+ const shown = found.slice(0, limit ?? DEFAULT_LIMIT);
153
+
154
+ context.audit.annotate({ scanned: all.length, matched: found.length });
155
+
156
+ if (shown.length === 0) {
157
+ const scope = status ? 'matching' : 'outstanding';
158
+ return {
159
+ content: [
160
+ { type: 'text', text: `No ${scope} tasks on ${context.connection.key}.` },
161
+ ],
162
+ };
163
+ }
164
+
165
+ // `resource_link` rather than a URI written into the text: core routes
166
+ // the link to the profile and connection this call was made on, and a
167
+ // provider must not learn either.
168
+ return {
169
+ content: [
170
+ ...shown.flatMap((task) => [
171
+ {
172
+ type: 'resource_link' as const,
173
+ uri: `tasks://task/${encodeURIComponent(task.id)}`,
174
+ name: task.title,
175
+ },
176
+ { type: 'text' as const, text: `${task.id} ${line(task)}` },
177
+ ]),
178
+ ...(found.length > shown.length
179
+ ? [
180
+ {
181
+ type: 'text' as const,
182
+ text: `… ${found.length - shown.length} more. Raise limit, or narrow with tag or query.`,
183
+ },
184
+ ]
185
+ : []),
186
+ ],
187
+ };
188
+ },
189
+ },
190
+
191
+ {
192
+ kind: 'tool',
193
+ name: 'get',
194
+ title: 'Read a task',
195
+ description:
196
+ 'Return one task by id, notes included. The resource tasks://task/{id} is the same content; this exists for clients that do not read resources.',
197
+ inputSchema: z.object({ id: z.string().min(1).describe('Task id') }),
198
+ redact: keepKeys('id'),
199
+ async handler({ id }, context) {
200
+ const task = await readTask(context.storage, id);
201
+
202
+ if (task === null) {
203
+ return {
204
+ content: [{ type: 'text', text: `No task "${id}" on ${context.connection.key}.` }],
205
+ isError: true,
206
+ };
207
+ }
208
+
209
+ return { content: [{ type: 'text', text: describe(task) }] };
210
+ },
211
+ },
212
+
213
+ {
214
+ kind: 'tool',
215
+ name: 'add',
216
+ title: 'Add a task',
217
+ description:
218
+ 'Record something to be done. This is where "remember to…", "add a todo", and "do not let me forget…" belong — not memory, which has no way to say a thing is finished.',
219
+ inputSchema: z.object({
220
+ title: z.string().min(1).describe('What is to be done, in one line'),
221
+ notes: z.string().optional().describe('Detail, as Markdown'),
222
+ status: statusSchema.optional().describe('Defaults to open'),
223
+ due: z
224
+ .string()
225
+ .optional()
226
+ .describe('When it is due, as the owner would write it — 2026-09-01, or an instant'),
227
+ tags: z.array(z.string()).optional().describe('Labels for filtering'),
228
+ id: z
229
+ .string()
230
+ .optional()
231
+ .describe('Task id. Derived from the title when omitted; naming an existing one replaces it.'),
232
+ }),
233
+ // The title and notes are the owner's own words; the rest is the shape of
234
+ // the change, which is what makes a write log worth having.
235
+ redact: keepKeys('id', 'status', 'due', 'tags'),
236
+ async handler({ title, notes, status, due, tags, id }, context) {
237
+ const taskId = id ?? slugify(title);
238
+ assertTaskId(taskId);
239
+
240
+ const now = new Date().toISOString();
241
+ const existing = await readTask(context.storage, taskId);
242
+
243
+ await writeTask(context.storage, {
244
+ id: taskId,
245
+ title,
246
+ status: status ?? 'open',
247
+ tags: tags ?? [],
248
+ ...(due ? { due } : {}),
249
+ // Preserved across a replace: when a task was first recorded is a fact
250
+ // about the task, not about the last time something touched it.
251
+ createdAt: existing?.createdAt ?? now,
252
+ updatedAt: now,
253
+ body: notes ?? '',
254
+ });
255
+
256
+ context.audit.annotate({ task: taskId, replaced: existing !== null });
257
+
258
+ return {
259
+ content: [
260
+ {
261
+ type: 'text',
262
+ text: `${existing ? 'Replaced' : 'Added'} task "${taskId}" on ${context.connection.key}.`,
263
+ },
264
+ { type: 'resource_link', uri: `tasks://task/${taskId}`, name: title },
265
+ ],
266
+ };
267
+ },
268
+ },
269
+
270
+ {
271
+ kind: 'tool',
272
+ name: 'update',
273
+ title: 'Change a task',
274
+ description:
275
+ 'Change a task in place — most often its status. Marking something done is an update, not a delete: the record of having done it is the useful part. Omitted fields are left as they are.',
276
+ inputSchema: z.object({
277
+ id: z.string().min(1).describe('Task id'),
278
+ status: statusSchema.optional().describe('The new status'),
279
+ title: z.string().optional().describe('Replaces the title'),
280
+ notes: z.string().optional().describe('Replaces the notes'),
281
+ due: z.string().optional().describe('Replaces the due date. Pass "" to clear it.'),
282
+ tags: z.array(z.string()).optional().describe('Replaces the tags'),
283
+ }),
284
+ redact: keepKeys('id', 'status', 'due', 'tags'),
285
+ async handler({ id, status, title, notes, due, tags }, context) {
286
+ const existing = await readTask(context.storage, id);
287
+
288
+ if (existing === null) {
289
+ return {
290
+ content: [{ type: 'text', text: `No task "${id}" on ${context.connection.key}.` }],
291
+ isError: true,
292
+ };
293
+ }
294
+
295
+ // An explicit empty string clears the date; `undefined` leaves it. The
296
+ // two are different intentions and a truthiness check would merge them.
297
+ const nextDue = due === undefined ? existing.due : due === '' ? undefined : due;
298
+
299
+ // `due` is pulled off `existing` rather than spread and overwritten,
300
+ // because spreading cannot *remove* a key: `{ ...existing, ...{} }`
301
+ // keeps the old date, which is precisely how clearing one silently
302
+ // failed to clear it.
303
+ const { due: _previous, ...rest } = existing;
304
+
305
+ await writeTask(context.storage, {
306
+ ...rest,
307
+ title: title ?? existing.title,
308
+ status: status ?? existing.status,
309
+ tags: tags ?? existing.tags,
310
+ ...(nextDue ? { due: nextDue } : {}),
311
+ updatedAt: new Date().toISOString(),
312
+ body: notes ?? existing.body,
313
+ });
314
+
315
+ context.audit.annotate({ task: id, from: existing.status, to: status ?? existing.status });
316
+
317
+ return {
318
+ content: [
319
+ {
320
+ type: 'text',
321
+ text: `Updated task "${id}" on ${context.connection.key} — now ${status ?? existing.status}.`,
322
+ },
323
+ ],
324
+ };
325
+ },
326
+ },
327
+
328
+ {
329
+ kind: 'tool',
330
+ name: 'remove',
331
+ title: 'Delete a task',
332
+ description:
333
+ 'Remove a task and its notes. For something that was finished or decided against, prefer update with status done or dropped — deleting loses the record that it happened.',
334
+ inputSchema: z.object({ id: z.string().min(1).describe('Task id') }),
335
+ redact: keepKeys('id'),
336
+ async handler({ id }, context) {
337
+ const existed = await context.storage.has(taskKey(id));
338
+ await context.storage.delete(taskKey(id));
339
+
340
+ return {
341
+ content: [
342
+ {
343
+ type: 'text',
344
+ text: existed
345
+ ? `Deleted task "${id}" from ${context.connection.key}.`
346
+ : `No task "${id}" on ${context.connection.key}.`,
347
+ },
348
+ ],
349
+ ...(existed ? {} : { isError: true }),
350
+ };
351
+ },
352
+ },
353
+ ],
354
+ });
355
+
356
+ /** One task as a document: the line a list would show, then the notes. */
357
+ function describe(task: Task): string {
358
+ const header = [
359
+ `# ${task.title}`,
360
+ '',
361
+ `status: ${task.status}`,
362
+ ...(task.due ? [`due: ${task.due}`] : []),
363
+ ...(task.tags.length > 0 ? [`tags: ${task.tags.join(', ')}`] : []),
364
+ `updated: ${task.updatedAt}`,
365
+ ];
366
+
367
+ return task.body.length > 0 ? `${header.join('\n')}\n\n${task.body}` : header.join('\n');
368
+ }
369
+
370
+ export default tasksProvider;