@lanes-sh/link 0.4.1 → 0.5.1

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 (45) 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 +53 -22
  9. package/src/cli/commands/operate/migrate.ts +100 -0
  10. package/src/cli/commands/operate/serve.ts +21 -0
  11. package/src/cli/commands/owner/assets.ts +132 -0
  12. package/src/cli/commands/owner/shared.ts +28 -4
  13. package/src/cli/commands/owner/tasks.ts +194 -0
  14. package/src/cli/commands/owner.ts +9 -4
  15. package/src/cli/config-edit.ts +33 -7
  16. package/src/cli/config-migrate.ts +251 -0
  17. package/src/cli/config-repair.ts +115 -11
  18. package/src/cli/dispatch-owner.ts +49 -8
  19. package/src/cli/lanes.ts +1 -1
  20. package/src/cli/main.ts +22 -3
  21. package/src/cli/provider-marks.ts +1 -1
  22. package/src/cli/runtime/registry.ts +10 -2
  23. package/src/cli/selection.ts +18 -0
  24. package/src/cli/usage.ts +20 -2
  25. package/src/connectivity/mail/attachments.ts +5 -1
  26. package/src/connectivity/mail/index.ts +6 -1
  27. package/src/connectivity/manifest/provider.ts +15 -2
  28. package/src/deployments/deploy.ts +3 -2
  29. package/src/deployments/prepare.ts +1 -1
  30. package/src/deployments/servable.ts +1 -1
  31. package/src/deployments/upload.ts +0 -53
  32. package/src/profile/index.ts +4 -0
  33. package/src/profile/load.ts +135 -1
  34. package/src/providers/assets/provider.ts +337 -0
  35. package/src/providers/assets/store.ts +167 -0
  36. package/src/providers/google/index.ts +1 -1
  37. package/src/providers/google/tasks/index.ts +3 -3
  38. package/src/providers/google/tasks/redact.ts +21 -11
  39. package/src/providers/index.ts +3 -3
  40. package/src/providers/owner.ts +39 -19
  41. package/src/providers/setup/plan.ts +17 -1
  42. package/src/providers/tasks/provider.ts +370 -0
  43. package/src/providers/tasks/store.ts +248 -0
  44. package/src/server/mcp/build.ts +1 -1
  45. package/src/server/mcp/instructions.ts +67 -8
@@ -1,9 +1,11 @@
1
1
  import { credentialRefFor, formatPlan, planIsNoop, planReconcile } from '#registry';
2
+ import { DEFAULT_SURFACES } from '../../config-repair.ts';
2
3
  import { announce, announceProfile, emit, fail, ok, print, warn } from '../../output.ts';
3
4
  import { staleNudge } from '../../release.ts';
4
- import { openRuntime, resolveProfileOnly, type GlobalFlags } from '../../runtime.ts';
5
+ import { openRuntime, resolveProfileOnly, type GlobalFlags, type Runtime } from '../../runtime.ts';
5
6
  import type { FetchLike } from '#deployments/knowledge.ts';
6
7
  import { credentialAge, reportCapabilityDrift } from './findings.ts';
8
+ import { migratedRenamedProviders } from './migrate.ts';
7
9
 
8
10
  /**
9
11
  * The gate order — check, doctor, plan, start — exists so failures surface in
@@ -40,6 +42,8 @@ export async function plan(flags: GlobalFlags): Promise<void> {
40
42
 
41
43
  export interface DoctorFlags extends GlobalFlags {
42
44
  readonly json?: boolean | undefined;
45
+ /** Apply a repair `doctor` would otherwise only report. */
46
+ readonly fix?: boolean | undefined;
43
47
  /** Injected for tests. A knowledge repository is the only thing doctor fetches. */
44
48
  readonly fetch?: FetchLike | undefined;
45
49
  }
@@ -62,7 +66,18 @@ export interface DoctorFinding {
62
66
 
63
67
  /** Read-only external checks: credentials resolve, stores reachable. */
64
68
  export async function doctor(flags: DoctorFlags): Promise<void> {
65
- const runtime = await openRuntime(flags, { fetch: flags.fetch });
69
+ // The one check that cannot use a runtime, because it answers for the profiles
70
+ // that cannot open one. A provider rename left in the config refuses at load,
71
+ // which takes every command down together — including the rest of this one —
72
+ // so it is asked first and, with `--fix`, undone. Anything else that refused
73
+ // is rethrown untouched.
74
+ let runtime: Runtime;
75
+ try {
76
+ runtime = await openRuntime(flags, { fetch: flags.fetch });
77
+ } catch (refusal) {
78
+ if (await migratedRenamedProviders(flags, refusal)) return;
79
+ throw refusal;
80
+ }
66
81
 
67
82
  const checks: string[] = [];
68
83
  const warnings: DoctorFinding[] = [];
@@ -180,33 +195,49 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
180
195
  }
181
196
  }
182
197
 
183
- // A profile written before the setup surface existed has no connection for
184
- // it, and `allowedConnections` returns nothing for a provider with no
185
- // connection *before* consulting policy — so the capabilities are simply
186
- // absent, with nothing saying why. An agent then has no way to see what is
187
- // configured and starts guessing at commands.
198
+ // A profile written before the owner layer was default has no connection row
199
+ // for any of it, and `allowedConnections` returns nothing for a provider with
200
+ // no connection *before* consulting policy — so the capabilities are simply
201
+ // absent, with nothing saying why. An agent then has no memory to consult, no
202
+ // list to add to, and no way to see what is configured, and starts guessing.
188
203
  //
189
204
  // Both halves, because either alone is inert: a connection row that no rule
190
205
  // grants serves nothing, and a rule naming a provider with no row is what
191
206
  // `allowedConnections` drops before policy is consulted. Reporting only the
192
- // row left the half-repaired profile reading as healthy while serving
193
- // exactly as little as the untouched one — and both halves are what
194
- // `ensureSetupConnection` writes, so this is the check that says whether it
195
- // has run.
196
- const hasSetupRow = runtime.config.connections.some(
197
- (connection) => connection.provider === 'setup',
198
- );
199
- const grantsSetup = runtime.config.policy.allow.some(
200
- (rule) => rule.capability === '*' || rule.capability === 'setup.*',
201
- );
207
+ // row left the half-repaired profile reading as healthy while serving exactly
208
+ // as little as the untouched one — and both halves are what
209
+ // `ensureOwnerLayer` writes, so this is the check that says whether it ran.
210
+ //
211
+ // A surface the operator has *denied* is not missing, it is off, so a deny
212
+ // covering it is not reported. That is the same rule the repair follows, and
213
+ // reading it here from `policy.deny` rather than asking the repair keeps
214
+ // `doctor` a read.
215
+ const denied = (rule: string): boolean =>
216
+ runtime.config.policy.deny.some(
217
+ (entry) => entry.capability === '*' || entry.capability === rule,
218
+ );
219
+
220
+ const missing = DEFAULT_SURFACES.filter((provider) => {
221
+ const rule = `${provider}.*`;
222
+ if (denied(rule)) return false;
223
+
224
+ const hasRow = runtime.config.connections.some(
225
+ (connection) => connection.provider === provider,
226
+ );
227
+ const granted = runtime.config.policy.allow.some(
228
+ (entry) => entry.capability === '*' || entry.capability === rule,
229
+ );
230
+ return !hasRow || !granted;
231
+ });
202
232
 
203
- if (!hasSetupRow || !grantsSetup) {
233
+ if (missing.length > 0) {
204
234
  warnings.push({
205
- kind: 'no_setup_connection',
235
+ kind: 'no_owner_layer',
206
236
  message:
207
- `this profile ${hasSetupRow ? 'does not grant "setup.*"' : 'has no "setup" connection'}` +
208
- ', so an agent cannot see what is configured run: lanes link connect setup',
209
- fix: forSelection('lanes link connect setup'),
237
+ `this profile cannot reach its own ${missing.join(', ')} ` +
238
+ 'the connection row or the allow rule is missing, and either alone serves nothing. ' +
239
+ 'Any of start, connect or deploy repairs it',
240
+ fix: forSelection('lanes link start'),
210
241
  });
211
242
  }
212
243
 
@@ -0,0 +1,100 @@
1
+ import { ConfigError, resolveSelection } from '#profile';
2
+ import { ConfigDocument } from '../../config-edit.ts';
3
+ import { migrateRenamedProviders, pendingRenames, shapeOf } from '../../config-migrate.ts';
4
+ import { emit, fail, ok, print, style, warn } from '../../output.ts';
5
+ import { openSecretStoreFor, type GlobalFlags } from '../../runtime.ts';
6
+
7
+ /**
8
+ * `doctor` answering for a profile whose config will not load.
9
+ *
10
+ * Here rather than in `inspect.ts` because it is the opposite of everything
11
+ * there: every other check reads a runtime, and this one runs precisely when no
12
+ * runtime can be opened. Keeping it beside them would have put a second
13
+ * `try`/`catch` shape around a file that is otherwise one long list of findings.
14
+ *
15
+ * Why `doctor` at all, and not a command of its own: it is already the command
16
+ * whose job is to say what is wrong and name the fix, and a `lanes link migrate`
17
+ * would be a command an operator has to know exists before their config breaks.
18
+ * `doctor` is what someone runs when something is broken, so it has to be the
19
+ * one that works when everything else refuses.
20
+ */
21
+
22
+ export interface RenameFlags extends GlobalFlags {
23
+ readonly json?: boolean | undefined;
24
+ /** Apply the migration rather than reporting it. */
25
+ readonly fix?: boolean | undefined;
26
+ }
27
+
28
+ /**
29
+ * Whether a refusal to load was a provider rename, and — with `--fix` — undo it.
30
+ *
31
+ * Returns false for anything else, so the caller rethrows the original error
32
+ * rather than replacing a real config problem with "nothing to migrate".
33
+ *
34
+ * The selection is resolved again here, and cheaply: `resolveSelection` reads
35
+ * the workspace and the flag, never a profile's config, which is what makes it
36
+ * usable on the path where the config is the thing that is broken.
37
+ */
38
+ export async function migratedRenamedProviders(
39
+ flags: RenameFlags,
40
+ refusal: unknown,
41
+ ): Promise<boolean> {
42
+ if (!(refusal instanceof ConfigError)) return false;
43
+
44
+ const selection = await resolveSelection({ profileFlag: flags.profile });
45
+ const document = await ConfigDocument.open(selection.workspaceRoot, selection.profile);
46
+ if (pendingRenames(document).length === 0) return false;
47
+
48
+ // Shape-only, because the check this document fails runs after the schema.
49
+ // Throws when it is malformed beyond a rename, which is a better sentence
50
+ // than the referential one it would otherwise be reported under.
51
+ const config = shapeOf(document);
52
+ const target = flags.target ?? '';
53
+ const credentials = await openSecretStoreFor(config, selection.workspaceRoot, target);
54
+
55
+ const migration = await migrateRenamedProviders(document, credentials, {
56
+ apply: flags.fix === true,
57
+ });
58
+
59
+ const applied = flags.fix === true && migration.changes.length > 0;
60
+
61
+ // A report is a problem, because the profile is still unusable. A repair that
62
+ // left nothing behind is not, and one that could not decide every row is —
63
+ // those rows are exactly as broken as before.
64
+ if (!applied || migration.blocked.length > 0) process.exitCode = 1;
65
+
66
+ await emit(
67
+ flags.json,
68
+ {
69
+ ok: applied && migration.blocked.length === 0,
70
+ profile: selection.profile,
71
+ target,
72
+ applied,
73
+ rows: migration.rows,
74
+ changes: migration.changes,
75
+ blocked: migration.blocked,
76
+ },
77
+ () => {
78
+ print(`profile ${style.bold(selection.profile)} target ${style.bold(target)}`);
79
+ print();
80
+
81
+ if (applied) {
82
+ print(ok(`${document.path} no longer names a provider that has moved`));
83
+ } else {
84
+ print(fail(`${document.path} names a provider that has moved, so nothing can load it`));
85
+ }
86
+
87
+ for (const change of migration.changes) {
88
+ print(` ${style.dim(applied ? change : `would ${change}`)}`);
89
+ }
90
+ for (const problem of migration.blocked) print(warn(problem));
91
+
92
+ if (!applied && migration.changes.length > 0) {
93
+ print();
94
+ print(`Run the same command with ${style.bold('--fix')} to apply it.`);
95
+ }
96
+ },
97
+ );
98
+
99
+ return true;
100
+ }
@@ -1,5 +1,6 @@
1
1
  import { startEndpoint } from '#server/endpoint.ts';
2
2
  import { streamLogger } from '#server/logging.ts';
3
+ import { repairOwnerLayer } from '../../config-repair.ts';
3
4
  import { announce, ok, print, style, warn } from '../../output.ts';
4
5
  import { staleNudge } from '../../release.ts';
5
6
  import { resolveProfile, type GlobalFlags } from '../../runtime.ts';
@@ -16,6 +17,26 @@ export async function start(
16
17
  const { resolution } = await resolveProfile(flags);
17
18
  announce(resolution);
18
19
 
20
+ // Before the bootstrap, and only here.
21
+ //
22
+ // This is the one command an existing install runs without being told to, so
23
+ // it is how a profile written before ADR-050 comes to have memory, tasks,
24
+ // assets, skills and the vault at all — `connect` and `deploy` repair too, but
25
+ // someone who is already set up may not run either for months. What it writes
26
+ // is the rows and rules a fresh profile is created with; a `deny` covering a
27
+ // surface is left alone, which is how one stays off.
28
+ //
29
+ // Not inside `startEndpoint`, which the container entrypoint also calls: a
30
+ // deployed revision holds `objectViewer` on `profiles/` (ADR-023) and must not
31
+ // be the thing that edits config. The repair belongs to the control plane
32
+ // (ADR-007), and this is the control plane.
33
+ //
34
+ // Scoped as the serving is: `--only` serves one profile, so it repairs one.
35
+ await repairOwnerLayer(
36
+ resolution.workspaceRoot,
37
+ flags.only ? [resolution.profile] : undefined,
38
+ );
39
+
19
40
  // The bootstrap itself lives in `endpoint.ts`, shared with the container
20
41
  // entrypoint. What stays here is what a terminal wants: the plan, printed as
21
42
  // it is applied, and the endpoint at the end.
@@ -0,0 +1,132 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { basename } from 'node:path';
3
+ import { ConfigError } from '#profile';
4
+ import { scopeNamespace } from '#dispatch';
5
+ import { scopeBlobStore, type BlobStore } from '#stores/blobs';
6
+ import { assetStorage } from '#providers/owner.ts';
7
+ import { heading, ok, print, style, table } from '../../output.ts';
8
+ import type { Runtime } from '../../runtime.ts';
9
+ import {
10
+ agreed,
11
+ ownerConnection,
12
+ required,
13
+ withRuntime,
14
+ type OwnerFlags,
15
+ } from './shared.ts';
16
+
17
+ /** `lanes link assets` — files the owner wants kept. */
18
+
19
+ export async function assetsList(flags: OwnerFlags): Promise<void> {
20
+ await withRuntime(flags, async (runtime) => {
21
+ const assets = await assetStorage.all(assetsStore(runtime, flags));
22
+
23
+ heading(`Assets (${assets.length})`);
24
+ if (assets.length === 0) {
25
+ print(style.dim(' none — keep one with: lanes link assets add <file>'));
26
+ return;
27
+ }
28
+
29
+ table(
30
+ assets.map((asset) => [
31
+ ` ${asset.name}`,
32
+ style.dim(asset.contentType),
33
+ style.dim(assetStorage.humanBytes(asset.bytes)),
34
+ style.dim(asset.modifiedAt.slice(0, 10)),
35
+ ]),
36
+ );
37
+ });
38
+ }
39
+
40
+ /**
41
+ * `lanes link assets add <file>` — a path on this machine.
42
+ *
43
+ * Only a path, where the capability takes five sources. The other four exist
44
+ * because the endpoint may not be on the caller's machine; a CLI is, by
45
+ * definition, already there, so a URL or a staged handle here would be
46
+ * ceremony over `curl -O`.
47
+ */
48
+ export async function assetsAdd(path: string | undefined, flags: OwnerFlags): Promise<void> {
49
+ const from = required(path, 'lanes link assets add <file>');
50
+
51
+ let bytes: Uint8Array;
52
+ try {
53
+ bytes = new Uint8Array(await readFile(from));
54
+ } catch (failure) {
55
+ const code = (failure as { code?: string }).code;
56
+ if (code === 'ENOENT') throw new ConfigError(`No file at ${from}.`);
57
+ if (code === 'EISDIR') throw new ConfigError(`${from} is a directory, not a file.`);
58
+ throw new ConfigError(`Could not read ${from} — ${(failure as Error).message}`);
59
+ }
60
+
61
+ const name = flags.name ?? basename(from);
62
+ assetStorage.assertName(name);
63
+
64
+ await withRuntime(flags, async (runtime) => {
65
+ const store = assetsStore(runtime, flags);
66
+ const replaced = await store.has(name);
67
+
68
+ // Only when told. Left off, the store infers from the extension and writes no
69
+ // sidecar; `--content-type` is for the file whose name does not say what it
70
+ // is, which is the only case worth a `<name>.meta` beside it.
71
+ await store.put(name, bytes, flags.contentType ? { contentType: flags.contentType } : {});
72
+
73
+ print(
74
+ ok(
75
+ `${replaced ? 'replaced' : 'kept'} ${style.bold(name)} — ` +
76
+ `${assetStorage.humanBytes(bytes.byteLength)}, sha256 ${assetStorage.digest(bytes).slice(0, 12)}…`,
77
+ ),
78
+ );
79
+ });
80
+ }
81
+
82
+ /**
83
+ * `lanes link assets get <name>` — write the bytes to stdout.
84
+ *
85
+ * Bytes rather than a description, unlike the capability: this end of the pipe is
86
+ * a shell, so `lanes link assets get invoice.pdf > invoice.pdf` is the useful
87
+ * thing and there is no context window to protect. Refuses a terminal for the
88
+ * same reason `curl` warns about it — binary into a tty is a mess nobody wanted.
89
+ */
90
+ export async function assetsGet(name: string | undefined, flags: OwnerFlags): Promise<void> {
91
+ const wanted = required(name, 'lanes link assets get <name> > <file>');
92
+
93
+ await withRuntime({ ...flags, raw: true }, async (runtime) => {
94
+ const store = assetsStore(runtime, flags);
95
+ const bytes = await store.get(wanted);
96
+ if (bytes === null) throw new ConfigError(`No asset "${wanted}" in this profile.`);
97
+
98
+ if (process.stdout.isTTY) {
99
+ throw new ConfigError(
100
+ `"${wanted}" would be written to your terminal. Redirect it:\n` +
101
+ ` lanes link assets get ${wanted} > ${wanted}`,
102
+ );
103
+ }
104
+
105
+ await Bun.write(Bun.stdout, bytes);
106
+ });
107
+ }
108
+
109
+ export async function assetsRemove(name: string | undefined, flags: OwnerFlags): Promise<void> {
110
+ const wanted = required(name, 'lanes link assets remove <name>');
111
+
112
+ await withRuntime(flags, async (runtime) => {
113
+ const store = assetsStore(runtime, flags);
114
+ const asset = await assetStorage.find(store, wanted);
115
+ if (!asset) throw new ConfigError(`No asset "${wanted}" in this profile.`);
116
+
117
+ print(` ${style.bold(asset.name)} ${assetStorage.describe(asset)}`);
118
+ if (!(await agreed(flags, 'Delete this file?'))) return;
119
+
120
+ await store.delete(wanted);
121
+ print(ok(`deleted ${style.bold(wanted)}`));
122
+ });
123
+ }
124
+
125
+ /**
126
+ * The blob namespace core would scope this provider to — see `tasks.ts` for why
127
+ * it is built from the same two functions rather than spelled as a path.
128
+ */
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));
132
+ }
@@ -4,11 +4,11 @@ import { confirm } from '../../prompt.ts';
4
4
  import { openRuntime, type GlobalFlags, type Runtime } from '../../runtime.ts';
5
5
 
6
6
  /**
7
- * What `lanes link memory`, `lanes link skills` and `lanes link vault` all need: the flag shape, the
8
- * runtime wrapper, connection resolution, and the two prompts.
7
+ * What every `lanes link` command over the owner's own data needs: the flag
8
+ * shape, the runtime wrapper, connection resolution, and the two prompts.
9
9
  *
10
- * All four commands are the same shape — open a runtime, announce, act, close —
11
- * so the wrapper lives here rather than three times over.
10
+ * All of them are the same shape — open a runtime, announce, act, close — so the
11
+ * wrapper lives here rather than once per noun.
12
12
  */
13
13
 
14
14
  export interface OwnerFlags extends GlobalFlags {
@@ -18,6 +18,14 @@ export interface OwnerFlags extends GlobalFlags {
18
18
  readonly tag?: string | undefined;
19
19
  readonly description?: string | undefined;
20
20
  readonly file?: string | undefined;
21
+ /** `tasks`: which status to set, or to filter a listing by. */
22
+ readonly status?: string | undefined;
23
+ /** `tasks`: when it is due, as the owner would write it. Empty clears it. */
24
+ readonly due?: string | undefined;
25
+ /** `assets`: what to call the stored file, where the path's basename is wrong. */
26
+ readonly name?: string | undefined;
27
+ /** `assets`: for the file whose extension does not say what it is. */
28
+ readonly contentType?: string | undefined;
21
29
  /** Reveal a vault value on a terminal. */
22
30
  readonly show?: boolean | undefined;
23
31
  /** Print only the value, for `$(…)`. */
@@ -102,6 +110,22 @@ export async function readStdin(usage: string, what: string): Promise<string> {
102
110
  return text;
103
111
  }
104
112
 
113
+ /**
114
+ * Read stdin where there may legitimately be nothing on it.
115
+ *
116
+ * `readStdin` refuses both a terminal and an empty pipe, which is right for
117
+ * `memory write` and `vault set` — a command whose whole subject arrived empty
118
+ * has been mis-invoked. It is wrong for `tasks add`, whose subject is the title
119
+ * on argv and whose notes are optional: refusing there made
120
+ * `lanes link tasks add "x"` fail in every non-interactive context — a script, a
121
+ * cron, a `< /dev/null` — while working by hand, which is the worst shape for a
122
+ * bug to have.
123
+ */
124
+ export async function optionalStdin(): Promise<string> {
125
+ if (process.stdin.isTTY) return '';
126
+ return (await Bun.stdin.text()).replace(/\n$/, '');
127
+ }
128
+
105
129
  /** Confirm a destructive action, unless `--yes` already answered. */
106
130
  export async function agreed(flags: OwnerFlags, question: string): Promise<boolean> {
107
131
  if (flags.yes) return true;
@@ -0,0 +1,194 @@
1
+ import { ConfigError } from '#profile';
2
+ import { scopeNamespace } from '#dispatch';
3
+ import { scopeBlobStore, type BlobStore } from '#stores/blobs';
4
+ import { ACTIVE_STATUSES, TASK_STATUSES, taskStorage, type TaskStatus } from '#providers/owner.ts';
5
+ import { heading, ok, print, style, table } from '../../output.ts';
6
+ import type { Runtime } from '../../runtime.ts';
7
+ import {
8
+ agreed,
9
+ optionalStdin,
10
+ ownerConnection,
11
+ required,
12
+ withRuntime,
13
+ type OwnerFlags,
14
+ } from './shared.ts';
15
+
16
+ /** `lanes link tasks` — what the owner has to do. */
17
+
18
+ export async function tasksList(flags: OwnerFlags): Promise<void> {
19
+ await withRuntime(flags, async (runtime) => {
20
+ const store = tasksStore(runtime, flags);
21
+
22
+ // The same default the `tasks.list` capability applies, and for the same
23
+ // reason: the question is what is outstanding, and a list that grows forever
24
+ // is one nobody reads. `--status all` is the escape hatch.
25
+ const wanted = statusFilter(flags.status);
26
+ const tasks = (await taskStorage.all(store)).filter(
27
+ (task) =>
28
+ (wanted === null || wanted.has(task.status)) && (!flags.tag || task.tags.includes(flags.tag)),
29
+ );
30
+
31
+ heading(`Tasks (${tasks.length}${wanted === null ? '' : ' outstanding'})`);
32
+ if (tasks.length === 0) {
33
+ print(style.dim(' none — add one with: lanes link tasks add <title>'));
34
+ return;
35
+ }
36
+
37
+ table(
38
+ tasks.map((task) => [
39
+ ` ${task.id}`,
40
+ task.status,
41
+ task.title,
42
+ task.due ? style.dim(`due ${task.due}`) : '',
43
+ task.tags.length > 0 ? style.dim(task.tags.join(', ')) : '',
44
+ ]),
45
+ );
46
+ });
47
+ }
48
+
49
+ export async function tasksGet(id: string | undefined, flags: OwnerFlags): Promise<void> {
50
+ const taskId = required(id, 'lanes link tasks get <id>');
51
+
52
+ await withRuntime(flags, async (runtime) => {
53
+ const task = await taskStorage.read(tasksStore(runtime, flags), taskId);
54
+ if (!task) throw new ConfigError(`No task "${taskId}" in this profile.`);
55
+
56
+ print('');
57
+ print(` ${style.bold(task.title)}`);
58
+ print(style.dim(` ${task.status}${task.due ? ` due ${task.due}` : ''}`));
59
+ if (task.tags.length > 0) print(style.dim(` ${task.tags.join(', ')}`));
60
+ if (task.body.length > 0) {
61
+ print('');
62
+ print(task.body);
63
+ }
64
+ });
65
+ }
66
+
67
+ /**
68
+ * `lanes link tasks add <title>` — the title on argv, notes optional on stdin.
69
+ *
70
+ * Unlike `memory write`, the title is the argument and the body is optional: a
71
+ * task is usually one line, and demanding a heredoc to write "chase the invoice"
72
+ * would make the common case the awkward one. So `optionalStdin` rather than
73
+ * `readStdin` — see its docstring for what refusing an empty pipe here broke.
74
+ */
75
+ export async function tasksAdd(title: string | undefined, flags: OwnerFlags): Promise<void> {
76
+ const given = required(title, 'lanes link tasks add <title> (notes on stdin, optional)');
77
+ const notes = await optionalStdin();
78
+
79
+ await withRuntime(flags, async (runtime) => {
80
+ const store = tasksStore(runtime, flags);
81
+ const id = taskStorage.slugify(given);
82
+ const existing = await taskStorage.read(store, id);
83
+ const now = new Date().toISOString();
84
+
85
+ await taskStorage.write(store, {
86
+ id,
87
+ title: given,
88
+ status: assertStatus(flags.status) ?? 'open',
89
+ tags: flags.tag ? [flags.tag] : (existing?.tags ?? []),
90
+ ...(flags.due ? { due: flags.due } : {}),
91
+ createdAt: existing?.createdAt ?? now,
92
+ updatedAt: now,
93
+ body: notes,
94
+ });
95
+
96
+ print(ok(`${existing ? 'replaced' : 'added'} task ${style.bold(id)}`));
97
+ });
98
+ }
99
+
100
+ /**
101
+ * `lanes link tasks update <id> --status done`.
102
+ *
103
+ * Omitted flags leave their fields alone, which is what makes this the way to
104
+ * close a task rather than delete it: the record of having done it is the useful
105
+ * part, and `--status done` keeps everything else.
106
+ */
107
+ export async function tasksUpdate(id: string | undefined, flags: OwnerFlags): Promise<void> {
108
+ const taskId = required(id, 'lanes link tasks update <id> --status <status>');
109
+
110
+ await withRuntime(flags, async (runtime) => {
111
+ const store = tasksStore(runtime, flags);
112
+ const existing = await taskStorage.read(store, taskId);
113
+ if (!existing) throw new ConfigError(`No task "${taskId}" in this profile.`);
114
+
115
+ const status = assertStatus(flags.status);
116
+ if (!status && !flags.title && !flags.due && !flags.tag) {
117
+ throw new ConfigError(
118
+ `Nothing to change. Pass --status, --title, --due or --tag.\n` +
119
+ ` statuses: ${TASK_STATUSES.join(', ')}`,
120
+ );
121
+ }
122
+
123
+ // `due` is taken off the existing record rather than spread and overwritten,
124
+ // because spreading cannot remove a key — the same trap the provider's
125
+ // `update` documents.
126
+ const { due: previous, ...rest } = existing;
127
+ const due = flags.due === '' ? undefined : (flags.due ?? previous);
128
+
129
+ await taskStorage.write(store, {
130
+ ...rest,
131
+ title: flags.title ?? existing.title,
132
+ status: status ?? existing.status,
133
+ tags: flags.tag ? [flags.tag] : existing.tags,
134
+ ...(due ? { due } : {}),
135
+ updatedAt: new Date().toISOString(),
136
+ });
137
+
138
+ print(ok(`updated task ${style.bold(taskId)} — now ${status ?? existing.status}`));
139
+ });
140
+ }
141
+
142
+ export async function tasksRemove(id: string | undefined, flags: OwnerFlags): Promise<void> {
143
+ const taskId = required(id, 'lanes link tasks remove <id>');
144
+
145
+ await withRuntime(flags, async (runtime) => {
146
+ const store = tasksStore(runtime, flags);
147
+ const task = await taskStorage.read(store, taskId);
148
+ if (!task) throw new ConfigError(`No task "${taskId}" in this profile.`);
149
+
150
+ print(` ${style.bold(task.id)} ${task.status} ${task.title}`);
151
+ print(
152
+ style.dim(' deleting loses the record that it happened — "update --status done" keeps it'),
153
+ );
154
+ if (!(await agreed(flags, 'Delete this task?'))) return;
155
+
156
+ await store.delete(taskStorage.key(taskId));
157
+ print(ok(`deleted task ${style.bold(taskId)}`));
158
+ });
159
+ }
160
+
161
+ /**
162
+ * `--status` as a filter: a named one, `all`, or the outstanding set by default.
163
+ *
164
+ * `null` means every status. Returning a set rather than a predicate so the
165
+ * heading can say whether it narrowed.
166
+ */
167
+ function statusFilter(raw: string | undefined): Set<TaskStatus> | null {
168
+ if (raw === 'all') return null;
169
+ if (raw === undefined) return new Set(ACTIVE_STATUSES);
170
+ return new Set([assertStatus(raw)!]);
171
+ }
172
+
173
+ function assertStatus(raw: string | undefined): TaskStatus | undefined {
174
+ if (raw === undefined) return undefined;
175
+ if (!(TASK_STATUSES as readonly string[]).includes(raw)) {
176
+ throw new ConfigError(
177
+ `Unknown status "${raw}". One of: ${TASK_STATUSES.join(', ')}` +
178
+ '\n (or "all", when filtering a listing)',
179
+ );
180
+ }
181
+ return raw as TaskStatus;
182
+ }
183
+
184
+ /**
185
+ * The blob namespace core would scope this provider to.
186
+ *
187
+ * Built from `scopeNamespace` and `scopeBlobStore` — the same two functions
188
+ * `buildProviderContext` uses — rather than from a path spelled out again, so the
189
+ * CLI cannot address a different directory from the provider.
190
+ */
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));
194
+ }
@@ -1,5 +1,6 @@
1
1
  /**
2
- * `lanes link memory`, `lanes link skills`, `lanes link vault` — the owner layer's control plane.
2
+ * `lanes link memory`, `tasks`, `assets`, `skills`, `vault` — the owner layer's
3
+ * control plane.
3
4
  *
4
5
  * The layer shipped in M4 with no CLI at all, so the two stores holding the
5
6
  * owner's *own* data were reachable only by an agent, and the one thing that
@@ -20,9 +21,9 @@
20
21
  * both sides now: from here because this is the owner's control plane, and over
21
22
  * MCP because ADR-014 §1 decided a policy-gated grant beats a missing path.
22
23
  *
23
- * One noun per file — `memory.ts`, `skills.ts`, `vault.ts` — over the shape all
24
- * three share in `shared.ts`: the flag type, the open-announce-act-close
25
- * wrapper, connection resolution, and the two prompts.
24
+ * One noun per file — `memory.ts`, `tasks.ts`, `assets.ts`, `skills.ts`,
25
+ * `vault.ts` — over the shape they all share in `shared.ts`: the flag type, the
26
+ * open-announce-act-close wrapper, connection resolution, and the two prompts.
26
27
  */
27
28
 
28
29
  export {
@@ -33,6 +34,10 @@ export {
33
34
  memoryWrite,
34
35
  } from './owner/memory.ts';
35
36
 
37
+ export { tasksAdd, tasksGet, tasksList, tasksRemove, tasksUpdate } from './owner/tasks.ts';
38
+
39
+ export { assetsAdd, assetsGet, assetsList, assetsRemove } from './owner/assets.ts';
40
+
36
41
  export { skillsAdd, skillsList, skillsRemove, skillsShow } from './owner/skills.ts';
37
42
 
38
43
  export {