synomem 0.1.1 → 0.3.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 (73) hide show
  1. package/ARCHITECTURE.md +2 -2
  2. package/CHANGELOG.md +37 -0
  3. package/README.md +26 -13
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +379 -51
  6. package/dist/cli.js.map +1 -1
  7. package/dist/client.d.ts +131 -17
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +489 -76
  10. package/dist/client.js.map +1 -1
  11. package/dist/config.d.ts +2 -2
  12. package/dist/config.js +8 -8
  13. package/dist/import.d.ts +356 -13
  14. package/dist/import.d.ts.map +1 -1
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +2 -2
  18. package/dist/index.js.map +1 -1
  19. package/dist/mcp/index.d.ts.map +1 -1
  20. package/dist/mcp/index.js +213 -31
  21. package/dist/mcp/index.js.map +1 -1
  22. package/dist/mcp-server.js +54 -8
  23. package/dist/mcp-server.js.map +1 -1
  24. package/dist/ports/repository.d.ts +21 -1
  25. package/dist/ports/repository.d.ts.map +1 -1
  26. package/dist/projections.d.ts +26 -1
  27. package/dist/projections.d.ts.map +1 -1
  28. package/dist/projections.js +208 -44
  29. package/dist/projections.js.map +1 -1
  30. package/dist/remote.d.ts +82 -9
  31. package/dist/remote.d.ts.map +1 -1
  32. package/dist/remote.js +53 -2
  33. package/dist/remote.js.map +1 -1
  34. package/dist/schemas.d.ts +476 -21
  35. package/dist/schemas.d.ts.map +1 -1
  36. package/dist/schemas.js +204 -26
  37. package/dist/schemas.js.map +1 -1
  38. package/dist/service.d.ts +82 -10
  39. package/dist/service.d.ts.map +1 -1
  40. package/dist/skill-install.d.ts +8 -2
  41. package/dist/skill-install.d.ts.map +1 -1
  42. package/dist/skill-install.js +6 -11
  43. package/dist/skill-install.js.map +1 -1
  44. package/dist/storage.d.ts +51 -1
  45. package/dist/storage.d.ts.map +1 -1
  46. package/dist/storage.js +366 -36
  47. package/dist/storage.js.map +1 -1
  48. package/dist/types.d.ts +310 -38
  49. package/dist/types.d.ts.map +1 -1
  50. package/docs/cli.md +53 -20
  51. package/docs/examples.md +4 -4
  52. package/docs/mcp.md +12 -4
  53. package/docs/skill.md +10 -7
  54. package/docs/storage-format.md +4 -4
  55. package/openapi/synomem-v1.yaml +24 -24
  56. package/package.json +1 -1
  57. package/skills/synomem/SKILL.md +24 -8
  58. package/skills/synomem/agents/openai.yaml +1 -1
  59. package/skills/synomem/references/examples.md +1 -1
  60. package/src/cli.ts +610 -95
  61. package/src/client.ts +587 -79
  62. package/src/config.ts +8 -8
  63. package/src/index.ts +11 -1
  64. package/src/mcp/index.ts +265 -30
  65. package/src/mcp-server.ts +58 -8
  66. package/src/ports/repository.ts +21 -0
  67. package/src/projections.ts +209 -44
  68. package/src/remote.ts +166 -8
  69. package/src/schemas.ts +211 -26
  70. package/src/service.ts +85 -7
  71. package/src/skill-install.ts +14 -17
  72. package/src/storage.ts +472 -34
  73. package/src/types.ts +332 -39
package/src/client.ts CHANGED
@@ -8,15 +8,21 @@ import {
8
8
  escapeMarkdown,
9
9
  memoRecordsFromEvents,
10
10
  noteRecordsFromEvents,
11
+ postRecordsFromEvents,
11
12
  ProjectionManager,
12
13
  recordsFromEvents,
14
+ taskRecordsFromEvents,
13
15
  todoRecordsFromEvents,
14
16
  } from './projections.js';
15
17
  import {
16
18
  actorSchema,
17
- agentIdSchema,
19
+ agentLookupSchema,
20
+ bindRuntimeSchema,
18
21
  createAgentSchema,
22
+ createPostSchema,
23
+ updatePostSchema,
19
24
  createNoteSchema,
25
+ createTaskSchema,
20
26
  createTodoSchema,
21
27
  changesInputSchema,
22
28
  giveKudosSchema,
@@ -24,6 +30,7 @@ import {
24
30
  listInputSchema,
25
31
  reviseNoteSchema,
26
32
  sendMemoSchema,
33
+ updateTaskSchema,
27
34
  updateTodoSchema,
28
35
  updateAgentSchema,
29
36
  } from './schemas.js';
@@ -39,8 +46,16 @@ import type { SynomemRepository } from './ports/repository.js';
39
46
  import type { ProjectionWriter } from './ports/projections.js';
40
47
  import type {
41
48
  ActorIdentity,
49
+ AgentDirectoryEntry,
42
50
  AgentProfile,
51
+ AgentResolution,
52
+ AgentRuntimeBinding,
53
+ BindRuntimeInput,
43
54
  CreateAgentInput,
55
+ CreatePostInput,
56
+ PostRecord,
57
+ PostRoster,
58
+ UpdatePostInput,
44
59
  Diagnostic,
45
60
  DoctorResult,
46
61
  GiveKudosInput,
@@ -52,9 +67,13 @@ import type {
52
67
  CreateNoteResult,
53
68
  ReviseNoteInput,
54
69
  NoteRecord,
70
+ CreateTaskInput,
55
71
  CreateTodoInput,
56
72
  CreateTodoResult,
73
+ CreateTaskResult,
74
+ UpdateTaskInput,
57
75
  UpdateTodoInput,
76
+ TaskRecord,
58
77
  TodoRecord,
59
78
  ItemListInput,
60
79
  ItemRecord,
@@ -101,6 +120,11 @@ export class SynomemCore implements SynomemDomainService {
101
120
  update: (id: string, changes: UpdateAgentInput) => this.updateAgent(id, changes),
102
121
  get: (idOrAlias: string) => this.getAgent(idOrAlias),
103
122
  list: () => this.listAgents(),
123
+ resolve: (query: string) => this.resolveAgent(query),
124
+ directory: () => this.agentDirectory(),
125
+ bindings: (idOrAlias: string) => this.listRuntimeBindings(idOrAlias),
126
+ bindRuntime: (input: BindRuntimeInput) => this.bindRuntime(input),
127
+ unbindRuntime: (bindingId: string) => this.unbindRuntime(bindingId),
104
128
  };
105
129
 
106
130
  readonly kudos = {
@@ -122,6 +146,27 @@ export class SynomemCore implements SynomemDomainService {
122
146
  archive: (input: { memoId: string; idempotencyKey?: string }) => this.archiveMemo(input),
123
147
  };
124
148
 
149
+ readonly posts = {
150
+ create: (input: CreatePostInput) => this.createPost(input),
151
+ list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
152
+ this.listItems({ ...input, kinds: ['post'] }),
153
+ get: (id: string) => this.getPost(id),
154
+ update: (input: UpdatePostInput) => this.updatePost(input),
155
+ archive: (input: { postId: string; reason?: string; idempotencyKey?: string }) =>
156
+ this.archivePost(input),
157
+ /*
158
+ * Acknowledging is an explicit call and always speaks for the caller alone.
159
+ * There is no bulk form and no acknowledge-on-behalf-of: an acknowledgement
160
+ * is one actor saying "I have seen this", and reading a post must never
161
+ * append one, or the roster stops meaning anything.
162
+ */
163
+ acknowledge: (input: { postId: string; note?: string; idempotencyKey?: string }) =>
164
+ this.acknowledgePost(input),
165
+ withdrawAcknowledgment: (input: { postId: string; reason?: string }) =>
166
+ this.withdrawPostAcknowledgment(input),
167
+ roster: (postId: string) => this.postRoster(postId),
168
+ };
169
+
125
170
  readonly notes = {
126
171
  create: (input: CreateNoteInput) => this.createNote(input),
127
172
  list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
@@ -131,20 +176,74 @@ export class SynomemCore implements SynomemDomainService {
131
176
  archive: (input: { noteId: string; idempotencyKey?: string }) => this.archiveNote(input),
132
177
  };
133
178
 
179
+ readonly tasks = {
180
+ create: (input: CreateTaskInput) => this.createTask(input),
181
+ list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
182
+ this.listItems({ ...input, kinds: ['task'] }),
183
+ get: (id: string) => this.getTask(id),
184
+ update: (input: UpdateTaskInput) => this.updateTask(input),
185
+ // A response is optional when accepting and required when rejecting: a
186
+ // refusal without a reason leaves the assigner unable to act on it.
187
+ accept: (input: { taskId: string; response?: string; idempotencyKey?: string }) =>
188
+ this.acceptTask(input),
189
+ reject: (input: { taskId: string; response: string; idempotencyKey?: string }) =>
190
+ this.rejectTask(input),
191
+ complete: (input: { taskId: string; note?: string; idempotencyKey?: string }) =>
192
+ this.completeTask(input),
193
+ reopen: (input: { taskId: string; idempotencyKey?: string }) => this.reopenTask(input),
194
+ cancel: (input: { taskId: string; reason?: string; idempotencyKey?: string }) =>
195
+ this.cancelTask(input),
196
+ };
197
+
134
198
  readonly todos = {
135
199
  create: (input: CreateTodoInput) => this.createTodo(input),
136
200
  list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
137
201
  this.listItems({ ...input, kinds: ['todo'] }),
138
202
  get: (id: string) => this.getTodo(id),
139
203
  update: (input: UpdateTodoInput) => this.updateTodo(input),
140
- accept: (input: { todoId: string; idempotencyKey?: string }) => this.acceptTodo(input),
141
- reject: (input: { todoId: string; reason?: string; idempotencyKey?: string }) =>
142
- this.rejectTodo(input),
143
204
  complete: (input: { todoId: string; note?: string; idempotencyKey?: string }) =>
144
- this.completeTodo(input),
145
- reopen: (input: { todoId: string; idempotencyKey?: string }) => this.reopenTodo(input),
205
+ this.todoTransition(input, 'todo.completed'),
206
+ reopen: (input: { todoId: string; idempotencyKey?: string }) =>
207
+ this.todoTransition(input, 'todo.reopened'),
146
208
  cancel: (input: { todoId: string; reason?: string; idempotencyKey?: string }) =>
147
- this.cancelTodo(input),
209
+ this.todoTransition(input, 'todo.canceled'),
210
+ archive: (input: { todoId: string; idempotencyKey?: string }) =>
211
+ this.todoTransition(input, 'todo.archived'),
212
+ };
213
+
214
+ /**
215
+ * Unanswered and overdue discovery.
216
+ *
217
+ * The plan asks for shared work to be observable without treating runtime
218
+ * metadata as a delivery guarantee. These are query states derived from
219
+ * durable events: they say nobody has answered yet, not that the agent was
220
+ * offline, missed a notification, or lacks a capability it claimed.
221
+ */
222
+ readonly discovery = {
223
+ /**
224
+ * Tasks awaiting acceptance, unread memos, and unacknowledged kudos —
225
+ * optionally only those older than a given age or instant.
226
+ */
227
+ unanswered: (
228
+ input: Omit<ItemListInput, 'awaitingResponse' | 'pending'> & { olderThanHours?: number } = {},
229
+ ) => {
230
+ const { olderThanHours, awaitingSince, ...rest } = input;
231
+ const since =
232
+ awaitingSince ??
233
+ (olderThanHours !== undefined
234
+ ? new Date(Date.now() - olderThanHours * 3_600_000).toISOString()
235
+ : undefined);
236
+ return this.listItems({
237
+ ...rest,
238
+ awaitingResponse: true,
239
+ ...(since ? { awaitingSince: since } : {}),
240
+ });
241
+ },
242
+ /** Open work whose deadline has passed. Defaults to "now". */
243
+ overdue: (input: Omit<ItemListInput, 'overdueAsOf'> & { asOf?: string } = {}) => {
244
+ const { asOf, ...rest } = input;
245
+ return this.listItems({ ...rest, overdueAsOf: asOf ?? new Date().toISOString() });
246
+ },
148
247
  };
149
248
 
150
249
  readonly items = {
@@ -251,7 +350,7 @@ export class SynomemCore implements SynomemDomainService {
251
350
  private async updateAgent(idOrAlias: string, changes: UpdateAgentInput): Promise<AgentProfile> {
252
351
  this.checkAbort();
253
352
  await this.repository.assertEventCompatibility();
254
- this.validate(() => agentIdSchema.parse(idOrAlias));
353
+ this.validate(() => agentLookupSchema.parse(idOrAlias));
255
354
  const parsed = this.validate(() => updateAgentSchema.parse(changes));
256
355
  const existing = await this.repository.getAgent(idOrAlias);
257
356
  if (!existing) throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
@@ -287,7 +386,7 @@ export class SynomemCore implements SynomemDomainService {
287
386
 
288
387
  private async getAgent(idOrAlias: string): Promise<AgentProfile> {
289
388
  this.checkAbort();
290
- this.validate(() => agentIdSchema.parse(idOrAlias));
389
+ this.validate(() => agentLookupSchema.parse(idOrAlias));
291
390
  const profile = await this.repository.getAgent(idOrAlias);
292
391
  if (!profile) throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
293
392
  return profile;
@@ -298,6 +397,77 @@ export class SynomemCore implements SynomemDomainService {
298
397
  return await this.repository.listAgents();
299
398
  }
300
399
 
400
+ /**
401
+ * Resolves a name without ever choosing between equally valid answers.
402
+ *
403
+ * Callers that want a single agent should treat an empty `match` as a
404
+ * question for the user, not as "not found": `candidates` distinguishes the
405
+ * two cases.
406
+ */
407
+ private async resolveAgent(query: string): Promise<AgentResolution> {
408
+ this.checkAbort();
409
+ const trimmed = query.trim();
410
+ if (!trimmed) throw new SynomemError('INVALID_INPUT', 'A lookup name is required.');
411
+ const resolved = await this.repository.resolveAgent(trimmed);
412
+ return {
413
+ query: trimmed,
414
+ ...(resolved.match ? { match: resolved.match } : {}),
415
+ candidates: resolved.candidates,
416
+ };
417
+ }
418
+
419
+ private async agentDirectory(): Promise<AgentDirectoryEntry[]> {
420
+ this.checkAbort();
421
+ const profiles = await this.repository.listAgents();
422
+ const entries: AgentDirectoryEntry[] = [];
423
+ for (const profile of profiles) {
424
+ entries.push({
425
+ profile,
426
+ runtimeBindings: await this.repository.listRuntimeBindings(profile.id),
427
+ });
428
+ }
429
+ return entries;
430
+ }
431
+
432
+ private async listRuntimeBindings(idOrAlias: string): Promise<AgentRuntimeBinding[]> {
433
+ const profile = await this.getAgent(idOrAlias);
434
+ return await this.repository.listRuntimeBindings(profile.id);
435
+ }
436
+
437
+ /**
438
+ * Records where an agent runs. Re-binding the same runtime and profile
439
+ * updates the claim in place rather than accumulating duplicates, because a
440
+ * reinstall is the same agent in the same place, not a second one.
441
+ */
442
+ private async bindRuntime(input: BindRuntimeInput): Promise<AgentRuntimeBinding> {
443
+ this.checkAbort();
444
+ const parsed = this.validate(() => bindRuntimeSchema.parse(input));
445
+ const profile = await this.getAgent(parsed.agentId);
446
+ await this.repository.bindRuntime({
447
+ id: this.idGenerator(),
448
+ agentId: profile.id,
449
+ ...(parsed.installationId !== undefined ? { installationId: parsed.installationId } : {}),
450
+ runtime: parsed.runtime,
451
+ ...(parsed.profile !== undefined ? { profile: parsed.profile } : {}),
452
+ ...(parsed.capabilities !== undefined ? { capabilities: parsed.capabilities } : {}),
453
+ boundAt: this.now(),
454
+ });
455
+ const bindings = await this.repository.listRuntimeBindings(profile.id);
456
+ const binding = bindings.find(
457
+ (candidate) =>
458
+ candidate.runtime === parsed.runtime &&
459
+ (candidate.profile ?? '') === (parsed.profile ?? '') &&
460
+ (candidate.installationId ?? '') === (parsed.installationId ?? ''),
461
+ );
462
+ if (!binding) throw new SynomemError('INTERNAL_ERROR', 'Runtime binding was not persisted.');
463
+ return binding;
464
+ }
465
+
466
+ private async unbindRuntime(bindingId: string): Promise<boolean> {
467
+ this.checkAbort();
468
+ return await this.repository.unbindRuntime(bindingId);
469
+ }
470
+
301
471
  private async giveKudos(input: GiveKudosInput): Promise<GiveKudosResult> {
302
472
  this.checkAbort();
303
473
  await this.repository.assertEventCompatibility();
@@ -493,7 +663,7 @@ export class SynomemCore implements SynomemDomainService {
493
663
  ? 'MEMO_NOT_FOUND'
494
664
  : kind === 'note'
495
665
  ? 'NOTE_NOT_FOUND'
496
- : kind === 'todo'
666
+ : kind === 'task'
497
667
  ? 'TODO_NOT_FOUND'
498
668
  : 'KUDOS_NOT_FOUND';
499
669
  throw new SynomemError(code, `Unknown ${kind}: ${id}`);
@@ -615,6 +785,174 @@ export class SynomemCore implements SynomemDomainService {
615
785
  return await this.getMemoRecord(input.memoId);
616
786
  }
617
787
 
788
+ private async getPostRecord(id: string): Promise<PostRecord> {
789
+ await this.requireVisibleItem(id, 'post');
790
+ const record = postRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
791
+ if (!record) throw new SynomemError('ITEM_NOT_FOUND', `Unknown post: ${id}`);
792
+ return record;
793
+ }
794
+
795
+ private async getPost(id: string): Promise<PostRecord> {
796
+ this.checkAbort();
797
+ return await this.getPostRecord(id);
798
+ }
799
+
800
+ private async createPost(input: CreatePostInput): Promise<{
801
+ record: PostRecord;
802
+ created: boolean;
803
+ deduplicated: boolean;
804
+ }> {
805
+ this.checkAbort();
806
+ await this.repository.assertEventCompatibility();
807
+ const parsed = this.validate(() => createPostSchema.parse(input));
808
+
809
+ // A reply inherits its parent's workspace by construction, and cannot name
810
+ // a different target — there is no target to name.
811
+ if (parsed.replyTo) await this.requireVisibleItem(parsed.replyTo, 'post');
812
+
813
+ const outcome = await this.repository.transaction(async () => {
814
+ const prior = await this.priorMutation(parsed.idempotencyKey, 'post.created');
815
+ if (prior?.type === 'post.created') return { id: prior.id, created: false };
816
+ const id = this.nextId();
817
+ const event: SynomemEvent = {
818
+ ...this.eventBase(id, 1, id),
819
+ type: 'post.created',
820
+ title: parsed.title,
821
+ body: parsed.body,
822
+ tags: [...new Set(parsed.tags ?? [])].sort(),
823
+ ...(parsed.replyTo ? { replyTo: parsed.replyTo } : {}),
824
+ ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
825
+ ...(parsed.source ? { source: parsed.source } : {}),
826
+ ...(parsed.metadata ? { metadata: parsed.metadata } : {}),
827
+ };
828
+ await this.repository.insertEvent(event);
829
+ return { id, created: true };
830
+ });
831
+ return {
832
+ record: await this.getPostRecord(outcome.id),
833
+ created: outcome.created,
834
+ deduplicated: !outcome.created,
835
+ };
836
+ }
837
+
838
+ /** Only the author edits a post. Everyone else responds to it. */
839
+ private assertPostAuthor(record: PostRecord): void {
840
+ if (this.administrative) return;
841
+ if (record.event.actor.id !== this.actor.id || record.event.actor.kind !== this.actor.kind) {
842
+ throw new SynomemError('MUTATION_FORBIDDEN', 'Only the author can change a post.');
843
+ }
844
+ }
845
+
846
+ private async updatePost(input: UpdatePostInput): Promise<PostRecord> {
847
+ this.checkAbort();
848
+ const parsed = this.validate(() => updatePostSchema.parse(input));
849
+ const record = await this.getPostRecord(parsed.postId);
850
+ this.assertPostAuthor(record);
851
+ if (record.status === 'archived') {
852
+ throw new SynomemError('MUTATION_FORBIDDEN', 'An archived post cannot be edited.');
853
+ }
854
+ if (record.version !== parsed.expectedVersion) {
855
+ throw new SynomemError(
856
+ 'REVISION_CONFLICT',
857
+ `Post ${parsed.postId} is at version ${record.version}.`,
858
+ );
859
+ }
860
+ await this.repository.transaction(async () => {
861
+ const event: SynomemEvent = {
862
+ ...this.eventBase(parsed.postId, await this.repository.nextAggregateVersion(parsed.postId)),
863
+ type: 'post.edited',
864
+ postId: parsed.postId,
865
+ title: parsed.title ?? record.title,
866
+ body: parsed.body ?? record.body,
867
+ tags: [...new Set(parsed.tags ?? record.tags ?? [])].sort(),
868
+ ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
869
+ };
870
+ await this.repository.insertEvent(event);
871
+ });
872
+ return await this.getPostRecord(parsed.postId);
873
+ }
874
+
875
+ private async archivePost(input: {
876
+ postId: string;
877
+ reason?: string;
878
+ idempotencyKey?: string;
879
+ }): Promise<PostRecord> {
880
+ this.checkAbort();
881
+ const record = await this.getPostRecord(input.postId);
882
+ this.assertPostAuthor(record);
883
+ if (record.status !== 'archived') {
884
+ await this.repository.transaction(async () => {
885
+ const event: SynomemEvent = {
886
+ ...this.eventBase(input.postId, await this.repository.nextAggregateVersion(input.postId)),
887
+ type: 'post.archived',
888
+ postId: input.postId,
889
+ ...(input.reason ? { reason: input.reason } : {}),
890
+ ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
891
+ };
892
+ await this.repository.insertEvent(event);
893
+ });
894
+ }
895
+ return await this.getPostRecord(input.postId);
896
+ }
897
+
898
+ private async acknowledgePost(input: {
899
+ postId: string;
900
+ note?: string;
901
+ idempotencyKey?: string;
902
+ }): Promise<PostRecord> {
903
+ this.checkAbort();
904
+ const record = await this.getPostRecord(input.postId);
905
+ // Acknowledging twice is the same statement, so the second is a no-op
906
+ // rather than a second row or an error.
907
+ const already = record.acknowledgments.some(
908
+ (entry) => entry.actor.id === this.actor.id && entry.actor.kind === this.actor.kind,
909
+ );
910
+ if (!already) {
911
+ await this.repository.transaction(async () => {
912
+ const event: SynomemEvent = {
913
+ ...this.eventBase(input.postId, await this.repository.nextAggregateVersion(input.postId)),
914
+ type: 'post.acknowledged',
915
+ postId: input.postId,
916
+ ...(input.note ? { note: input.note } : {}),
917
+ ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
918
+ };
919
+ await this.repository.insertEvent(event);
920
+ });
921
+ }
922
+ return await this.getPostRecord(input.postId);
923
+ }
924
+
925
+ private async withdrawPostAcknowledgment(input: {
926
+ postId: string;
927
+ reason?: string;
928
+ }): Promise<PostRecord> {
929
+ this.checkAbort();
930
+ const record = await this.getPostRecord(input.postId);
931
+ const mine = record.acknowledgments.some(
932
+ (entry) => entry.actor.id === this.actor.id && entry.actor.kind === this.actor.kind,
933
+ );
934
+ if (mine) {
935
+ await this.repository.transaction(async () => {
936
+ const event: SynomemEvent = {
937
+ ...this.eventBase(input.postId, await this.repository.nextAggregateVersion(input.postId)),
938
+ type: 'post.acknowledgment.withdrawn',
939
+ postId: input.postId,
940
+ ...(input.reason ? { reason: input.reason } : {}),
941
+ };
942
+ await this.repository.insertEvent(event);
943
+ });
944
+ }
945
+ return await this.getPostRecord(input.postId);
946
+ }
947
+
948
+ private async postRoster(postId: string): Promise<PostRoster> {
949
+ this.checkAbort();
950
+ await this.requireVisibleItem(postId, 'post');
951
+ const roster = await this.repository.postRoster(postId);
952
+ if (!roster) throw new SynomemError('ITEM_NOT_FOUND', `Unknown post: ${postId}`);
953
+ return roster;
954
+ }
955
+
618
956
  private async getNoteRecord(id: string): Promise<NoteRecord> {
619
957
  await this.requireVisibleItem(id, 'note');
620
958
  const record = noteRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
@@ -744,21 +1082,21 @@ export class SynomemCore implements SynomemDomainService {
744
1082
  return await this.getNoteRecord(input.noteId);
745
1083
  }
746
1084
 
747
- private async getTodoRecord(id: string): Promise<TodoRecord> {
748
- await this.requireVisibleItem(id, 'todo');
749
- const record = todoRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
750
- if (!record) throw new SynomemError('TODO_NOT_FOUND', `Unknown todo: ${id}`);
1085
+ private async getTaskRecord(id: string): Promise<TaskRecord> {
1086
+ await this.requireVisibleItem(id, 'task');
1087
+ const record = taskRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
1088
+ if (!record) throw new SynomemError('TODO_NOT_FOUND', `Unknown task: ${id}`);
751
1089
  return record;
752
1090
  }
753
- private async getTodo(id: string): Promise<TodoRecord> {
1091
+ private async getTask(id: string): Promise<TaskRecord> {
754
1092
  this.checkAbort();
755
- return await this.getTodoRecord(id);
1093
+ return await this.getTaskRecord(id);
756
1094
  }
757
1095
 
758
- private async createTodo(input: CreateTodoInput): Promise<CreateTodoResult> {
1096
+ private async createTask(input: CreateTaskInput): Promise<CreateTaskResult> {
759
1097
  this.checkAbort();
760
1098
  await this.repository.assertEventCompatibility();
761
- const parsed = this.validate(() => createTodoSchema.parse(input));
1099
+ const parsed = this.validate(() => createTaskSchema.parse(input));
762
1100
  const assigneeId =
763
1101
  parsed.assigneeAgentId ?? (this.actor.kind === 'agent' ? this.actor.id : undefined);
764
1102
  if (!assigneeId)
@@ -768,21 +1106,21 @@ export class SynomemCore implements SynomemDomainService {
768
1106
  );
769
1107
  const assignee = await this.repository.getAgent(assigneeId);
770
1108
  if (!assignee)
771
- throw new SynomemError('AGENT_NOT_FOUND', `Unknown todo assignee: ${assigneeId}`);
1109
+ throw new SynomemError('AGENT_NOT_FOUND', `Unknown task assignee: ${assigneeId}`);
772
1110
  if (
773
1111
  this.actor.kind === 'agent' &&
774
1112
  this.actor.id !== assignee.id &&
775
- !this.repository.config.allowCrossAgentTodos
1113
+ !this.repository.config.allowCrossAgentTasks
776
1114
  )
777
- throw new SynomemError('POLICY_FORBIDDEN', 'Cross-agent todo assignment is disabled.');
1115
+ throw new SynomemError('POLICY_FORBIDDEN', 'Cross-agent task assignment is disabled.');
778
1116
  const outcome = await this.repository.transaction(async () => {
779
- const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.created');
780
- if (prior?.type === 'todo.created') return { id: prior.id, created: false };
1117
+ const prior = await this.priorMutation(parsed.idempotencyKey, 'task.created');
1118
+ if (prior?.type === 'task.created') return { id: prior.id, created: false };
781
1119
  const id = this.nextId();
782
1120
  const requiresAcceptance = this.actor.kind !== 'agent' || this.actor.id !== assignee.id;
783
1121
  const event: SynomemEvent = {
784
1122
  ...this.eventBase(id, 1, id),
785
- type: 'todo.created',
1123
+ type: 'task.created',
786
1124
  assigneeAgentId: assignee.id,
787
1125
  assigneeDisplayName: assignee.displayName,
788
1126
  title: parsed.title,
@@ -801,13 +1139,13 @@ export class SynomemCore implements SynomemDomainService {
801
1139
  });
802
1140
  if (outcome.created) await this.projectionWriter.syncAgent(assignee.id);
803
1141
  return {
804
- record: await this.getTodoRecord(outcome.id),
1142
+ record: await this.getTaskRecord(outcome.id),
805
1143
  created: outcome.created,
806
1144
  deduplicated: !outcome.created,
807
1145
  };
808
1146
  }
809
1147
 
810
- private assertTodoParticipant(record: TodoRecord): void {
1148
+ private assertTaskParticipant(record: TaskRecord): void {
811
1149
  const isCreator =
812
1150
  record.event.actor.kind === this.actor.kind && record.event.actor.id === this.actor.id;
813
1151
  const isAssignee =
@@ -815,35 +1153,35 @@ export class SynomemCore implements SynomemDomainService {
815
1153
  if (!this.administrative && !isCreator && !isAssignee)
816
1154
  throw new SynomemError(
817
1155
  'MUTATION_FORBIDDEN',
818
- 'Only the todo creator, assignee, or a human administrator may change it.',
1156
+ 'Only the task creator, assignee, or a human administrator may change it.',
819
1157
  );
820
1158
  }
821
1159
 
822
- private assertTodoAssignee(record: TodoRecord): void {
1160
+ private assertTaskAssignee(record: TaskRecord): void {
823
1161
  if (
824
1162
  !this.administrative &&
825
1163
  !(this.actor.kind === 'agent' && this.actor.id === record.event.assigneeAgentId)
826
1164
  ) {
827
1165
  throw new SynomemError(
828
1166
  'MUTATION_FORBIDDEN',
829
- 'Only the assigned agent or a human administrator may accept or reject this todo.',
1167
+ 'Only the assigned agent or a human administrator may accept or reject this task.',
830
1168
  );
831
1169
  }
832
1170
  }
833
1171
 
834
- private async updateTodo(input: UpdateTodoInput): Promise<TodoRecord> {
835
- const parsed = this.validate(() => updateTodoSchema.parse(input));
836
- const record = await this.getTodoRecord(parsed.todoId);
837
- this.assertTodoParticipant(record);
1172
+ private async updateTask(input: UpdateTaskInput): Promise<TaskRecord> {
1173
+ const parsed = this.validate(() => updateTaskSchema.parse(input));
1174
+ const record = await this.getTaskRecord(parsed.taskId);
1175
+ this.assertTaskParticipant(record);
838
1176
  if (record.status !== 'open')
839
- throw new SynomemError('INVALID_INPUT', 'Only open todos can be updated.');
1177
+ throw new SynomemError('INVALID_INPUT', 'Only open tasks can be updated.');
840
1178
  if (parsed.expectedVersion !== record.current.version)
841
1179
  throw new SynomemError(
842
1180
  'REVISION_CONFLICT',
843
- `Expected todo version ${parsed.expectedVersion}; current version is ${record.current.version}.`,
1181
+ `Expected task version ${parsed.expectedVersion}; current version is ${record.current.version}.`,
844
1182
  );
845
1183
  await this.repository.transaction(async () => {
846
- const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.updated');
1184
+ const prior = await this.priorMutation(parsed.idempotencyKey, 'task.updated');
847
1185
  if (prior) return;
848
1186
  if (
849
1187
  (await this.repository.nextAggregateVersion(record.event.id)) !==
@@ -851,13 +1189,13 @@ export class SynomemCore implements SynomemDomainService {
851
1189
  )
852
1190
  throw new SynomemError(
853
1191
  'REVISION_CONFLICT',
854
- 'The todo changed before this update was stored.',
1192
+ 'The task changed before this update was stored.',
855
1193
  );
856
1194
  const due = parsed.due === null ? undefined : (parsed.due ?? record.current.due);
857
1195
  const event: SynomemEvent = {
858
1196
  ...this.eventBase(record.event.id, parsed.expectedVersion + 1),
859
- type: 'todo.updated',
860
- todoId: record.event.id,
1197
+ type: 'task.updated',
1198
+ taskId: record.event.id,
861
1199
  title: parsed.title ?? record.current.title,
862
1200
  ...(parsed.description !== undefined
863
1201
  ? { description: parsed.description }
@@ -875,35 +1213,49 @@ export class SynomemCore implements SynomemDomainService {
875
1213
  await this.repository.insertEvent(event);
876
1214
  });
877
1215
  await this.projectionWriter.syncAgent(record.event.assigneeAgentId);
878
- return await this.getTodoRecord(parsed.todoId);
1216
+ return await this.getTaskRecord(parsed.taskId);
879
1217
  }
880
1218
 
881
- private async todoTransition(
882
- input: { todoId: string; idempotencyKey?: string; note?: string; reason?: string },
883
- type: 'todo.accepted' | 'todo.rejected' | 'todo.completed' | 'todo.reopened' | 'todo.canceled',
884
- ): Promise<TodoRecord> {
885
- const record = await this.getTodoRecord(input.todoId);
886
- if (type === 'todo.accepted' || type === 'todo.rejected') this.assertTodoAssignee(record);
887
- else this.assertTodoParticipant(record);
888
- if ((type === 'todo.accepted' || type === 'todo.rejected') && record.status !== 'assigned') {
889
- if (type === 'todo.accepted' && record.status === 'open') return record;
890
- if (type === 'todo.rejected' && record.status === 'rejected') return record;
891
- throw new SynomemError('INVALID_INPUT', 'Only assigned todos may be accepted or rejected.');
1219
+ private async taskTransition(
1220
+ input: {
1221
+ taskId: string;
1222
+ idempotencyKey?: string;
1223
+ note?: string;
1224
+ reason?: string;
1225
+ response?: string;
1226
+ },
1227
+ type: 'task.accepted' | 'task.rejected' | 'task.completed' | 'task.reopened' | 'task.canceled',
1228
+ ): Promise<TaskRecord> {
1229
+ const record = await this.getTaskRecord(input.taskId);
1230
+ // A rejection must say why. Enforced here as well as in the schema so the
1231
+ // failure names the missing thing rather than surfacing as a parse error.
1232
+ if (type === 'task.rejected' && !input.response?.trim()) {
1233
+ throw new SynomemError(
1234
+ 'INVALID_INPUT',
1235
+ 'Rejecting a task requires a response explaining why, so the assigner knows whether to reassign it, wait, or change the request.',
1236
+ );
892
1237
  }
893
- if (type === 'todo.completed' && record.status !== 'open') {
1238
+ if (type === 'task.accepted' || type === 'task.rejected') this.assertTaskAssignee(record);
1239
+ else this.assertTaskParticipant(record);
1240
+ if ((type === 'task.accepted' || type === 'task.rejected') && record.status !== 'assigned') {
1241
+ if (type === 'task.accepted' && record.status === 'open') return record;
1242
+ if (type === 'task.rejected' && record.status === 'rejected') return record;
1243
+ throw new SynomemError('INVALID_INPUT', 'Only assigned tasks may be accepted or rejected.');
1244
+ }
1245
+ if (type === 'task.completed' && record.status !== 'open') {
894
1246
  if (record.status === 'completed') return record;
895
1247
  throw new SynomemError(
896
1248
  'INVALID_INPUT',
897
- 'Only accepted or self-created open todos may be completed.',
1249
+ 'Only accepted or self-created open tasks may be completed.',
898
1250
  );
899
1251
  }
900
- if (type === 'todo.reopened' && record.status !== 'completed' && record.status !== 'canceled') {
1252
+ if (type === 'task.reopened' && record.status !== 'completed' && record.status !== 'canceled') {
901
1253
  if (record.status === 'open') return record;
902
- throw new SynomemError('INVALID_INPUT', 'Only completed or canceled todos may be reopened.');
1254
+ throw new SynomemError('INVALID_INPUT', 'Only completed or canceled tasks may be reopened.');
903
1255
  }
904
- if (type === 'todo.canceled' && record.status !== 'assigned' && record.status !== 'open') {
1256
+ if (type === 'task.canceled' && record.status !== 'assigned' && record.status !== 'open') {
905
1257
  if (record.status === 'canceled') return record;
906
- throw new SynomemError('INVALID_INPUT', 'Only assigned or open todos may be canceled.');
1258
+ throw new SynomemError('INVALID_INPUT', 'Only assigned or open tasks may be canceled.');
907
1259
  }
908
1260
  await this.repository.transaction(async () => {
909
1261
  const prior = await this.priorMutation(input.idempotencyKey, type);
@@ -913,34 +1265,180 @@ export class SynomemCore implements SynomemDomainService {
913
1265
  record.event.id,
914
1266
  await this.repository.nextAggregateVersion(record.event.id),
915
1267
  ),
916
- todoId: record.event.id,
1268
+ taskId: record.event.id,
917
1269
  ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
918
1270
  };
919
1271
  const event: SynomemEvent =
920
- type === 'todo.completed'
1272
+ type === 'task.completed'
921
1273
  ? { ...base, type, ...(input.note ? { note: input.note.trim() } : {}) }
922
- : type === 'todo.canceled' || type === 'todo.rejected'
923
- ? { ...base, type, ...(input.reason ? { reason: input.reason.trim() } : {}) }
924
- : { ...base, type };
1274
+ : type === 'task.rejected'
1275
+ ? { ...base, type, response: input.response!.trim() }
1276
+ : type === 'task.accepted'
1277
+ ? {
1278
+ ...base,
1279
+ type,
1280
+ ...(input.response ? { response: input.response.trim() } : {}),
1281
+ }
1282
+ : type === 'task.canceled'
1283
+ ? { ...base, type, ...(input.reason ? { reason: input.reason.trim() } : {}) }
1284
+ : { ...base, type };
925
1285
  await this.repository.insertEvent(event);
926
1286
  });
927
1287
  await this.projectionWriter.syncAgent(record.event.assigneeAgentId);
928
- return await this.getTodoRecord(input.todoId);
1288
+ return await this.getTaskRecord(input.taskId);
929
1289
  }
930
- private completeTodo(input: { todoId: string; note?: string; idempotencyKey?: string }) {
931
- return this.todoTransition(input, 'todo.completed');
1290
+ private completeTask(input: { taskId: string; note?: string; idempotencyKey?: string }) {
1291
+ return this.taskTransition(input, 'task.completed');
932
1292
  }
933
- private acceptTodo(input: { todoId: string; idempotencyKey?: string }) {
934
- return this.todoTransition(input, 'todo.accepted');
1293
+ private acceptTask(input: { taskId: string; response?: string; idempotencyKey?: string }) {
1294
+ return this.taskTransition(input, 'task.accepted');
935
1295
  }
936
- private rejectTodo(input: { todoId: string; reason?: string; idempotencyKey?: string }) {
937
- return this.todoTransition(input, 'todo.rejected');
1296
+ private rejectTask(input: { taskId: string; response: string; idempotencyKey?: string }) {
1297
+ return this.taskTransition(input, 'task.rejected');
938
1298
  }
939
- private reopenTodo(input: { todoId: string; idempotencyKey?: string }) {
940
- return this.todoTransition(input, 'todo.reopened');
1299
+ private reopenTask(input: { taskId: string; idempotencyKey?: string }) {
1300
+ return this.taskTransition(input, 'task.reopened');
941
1301
  }
942
- private cancelTodo(input: { todoId: string; reason?: string; idempotencyKey?: string }) {
943
- return this.todoTransition(input, 'todo.canceled');
1302
+ private cancelTask(input: { taskId: string; reason?: string; idempotencyKey?: string }) {
1303
+ return this.taskTransition(input, 'task.canceled');
1304
+ }
1305
+
1306
+ /* ----------------------------------------------------------------- todos *
1307
+ * A Todo is a private reminder an agent creates for itself. There is no
1308
+ * assignee, no acceptance, and no visibility choice: it belongs to its author
1309
+ * and only its author reads it. Every method below asserts that ownership
1310
+ * rather than relying on a visibility filter, so a Todo cannot be reached by
1311
+ * guessing its id.
1312
+ */
1313
+
1314
+ private async createTodo(input: CreateTodoInput): Promise<CreateTodoResult> {
1315
+ this.checkAbort();
1316
+ await this.repository.assertEventCompatibility();
1317
+ const parsed = this.validate(() => createTodoSchema.parse(input));
1318
+ const outcome = await this.repository.transaction(async () => {
1319
+ const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.created');
1320
+ if (prior?.type === 'todo.created') return { id: prior.id, created: false };
1321
+ const id = this.nextId();
1322
+ const event: SynomemEvent = {
1323
+ ...this.eventBase(id, 1, id),
1324
+ type: 'todo.created',
1325
+ title: parsed.title,
1326
+ ...(parsed.details !== undefined ? { details: parsed.details } : {}),
1327
+ priority: parsed.priority ?? 3,
1328
+ ...(parsed.due ? { due: parsed.due } : {}),
1329
+ tags: [...new Set(parsed.tags ?? [])].sort(),
1330
+ ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
1331
+ ...(parsed.source ? { source: parsed.source } : {}),
1332
+ ...(parsed.metadata ? { metadata: parsed.metadata } : {}),
1333
+ };
1334
+ await this.repository.insertEvent(event);
1335
+ return { id, created: true };
1336
+ });
1337
+ return {
1338
+ record: await this.getTodoRecord(outcome.id),
1339
+ created: outcome.created,
1340
+ deduplicated: !outcome.created,
1341
+ };
1342
+ }
1343
+
1344
+ private async getTodoRecord(id: string): Promise<TodoRecord> {
1345
+ const record = todoRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
1346
+ if (!record) throw new SynomemError('ITEM_NOT_FOUND', `Unknown todo: ${id}`);
1347
+ this.assertTodoOwner(record);
1348
+ return record;
1349
+ }
1350
+
1351
+ /**
1352
+ * Todos are owner-only. No role, however broad, reads another actor's
1353
+ * private reminders — that is a named administrative capability this release
1354
+ * does not have, not something a permission check quietly allows.
1355
+ */
1356
+ private assertTodoOwner(record: TodoRecord): void {
1357
+ const owner = record.event.actor;
1358
+ if (owner.kind !== this.actor.kind || owner.id !== this.actor.id) {
1359
+ throw new SynomemError(
1360
+ 'MUTATION_FORBIDDEN',
1361
+ 'A todo is private to the actor who created it.',
1362
+ );
1363
+ }
1364
+ }
1365
+
1366
+ private async getTodo(id: string): Promise<TodoRecord> {
1367
+ this.checkAbort();
1368
+ return await this.getTodoRecord(id);
1369
+ }
1370
+
1371
+ private async updateTodo(input: UpdateTodoInput): Promise<TodoRecord> {
1372
+ this.checkAbort();
1373
+ const parsed = this.validate(() => updateTodoSchema.parse(input));
1374
+ const record = await this.getTodoRecord(parsed.todoId);
1375
+ if (record.current.version !== parsed.expectedVersion) {
1376
+ throw new SynomemError(
1377
+ 'REVISION_CONFLICT',
1378
+ `Todo ${parsed.todoId} is at version ${record.current.version}.`,
1379
+ );
1380
+ }
1381
+ const due = parsed.due === null ? undefined : (parsed.due ?? record.current.due);
1382
+ await this.repository.transaction(async () => {
1383
+ const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.updated');
1384
+ if (prior) return;
1385
+ const event: SynomemEvent = {
1386
+ ...this.eventBase(
1387
+ record.event.id,
1388
+ await this.repository.nextAggregateVersion(record.event.id),
1389
+ ),
1390
+ type: 'todo.updated',
1391
+ todoId: record.event.id,
1392
+ title: parsed.title ?? record.current.title,
1393
+ ...((parsed.details ?? record.current.details)
1394
+ ? { details: parsed.details ?? record.current.details }
1395
+ : {}),
1396
+ priority: parsed.priority ?? record.current.priority,
1397
+ ...(due ? { due } : {}),
1398
+ tags: [...new Set<string>(parsed.tags ?? record.current.tags)].sort(),
1399
+ ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
1400
+ };
1401
+ await this.repository.insertEvent(event);
1402
+ });
1403
+ return await this.getTodoRecord(parsed.todoId);
1404
+ }
1405
+
1406
+ private async todoTransition(
1407
+ input: { todoId: string; idempotencyKey?: string; note?: string; reason?: string },
1408
+ type: 'todo.completed' | 'todo.reopened' | 'todo.canceled' | 'todo.archived',
1409
+ ): Promise<TodoRecord> {
1410
+ const record = await this.getTodoRecord(input.todoId);
1411
+ if (type === 'todo.completed' && record.status !== 'open') {
1412
+ if (record.status === 'completed') return record;
1413
+ throw new SynomemError('INVALID_INPUT', 'Only an open todo may be completed.');
1414
+ }
1415
+ if (type === 'todo.reopened' && record.status === 'open') return record;
1416
+ if (type === 'todo.canceled' && record.status !== 'open') {
1417
+ if (record.status === 'canceled') return record;
1418
+ throw new SynomemError('INVALID_INPUT', 'Only an open todo may be canceled.');
1419
+ }
1420
+ if (type === 'todo.archived' && record.status === 'archived') return record;
1421
+
1422
+ await this.repository.transaction(async () => {
1423
+ const prior = await this.priorMutation(input.idempotencyKey, type);
1424
+ if (prior) return;
1425
+ const base = {
1426
+ ...this.eventBase(
1427
+ record.event.id,
1428
+ await this.repository.nextAggregateVersion(record.event.id),
1429
+ ),
1430
+ todoId: record.event.id,
1431
+ ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
1432
+ };
1433
+ const event: SynomemEvent =
1434
+ type === 'todo.completed'
1435
+ ? { ...base, type, ...(input.note ? { note: input.note.trim() } : {}) }
1436
+ : type === 'todo.canceled'
1437
+ ? { ...base, type, ...(input.reason ? { reason: input.reason.trim() } : {}) }
1438
+ : { ...base, type };
1439
+ await this.repository.insertEvent(event);
1440
+ });
1441
+ return await this.getTodoRecord(input.todoId);
944
1442
  }
945
1443
 
946
1444
  private async listItems(input: ItemListInput): Promise<Page<ItemSummary>> {
@@ -976,7 +1474,7 @@ export class SynomemCore implements SynomemDomainService {
976
1474
  if (summary.kind === 'kudos') return this.getKudos(id);
977
1475
  if (summary.kind === 'memo') return this.getMemo(id);
978
1476
  if (summary.kind === 'note') return this.getNote(id);
979
- return this.getTodo(id);
1477
+ return this.getTask(id);
980
1478
  }
981
1479
 
982
1480
  async stats(input: KudosListInput = {}): Promise<KudosStats> {
@@ -1035,6 +1533,15 @@ export class SynomemCore implements SynomemDomainService {
1035
1533
  }
1036
1534
  }
1037
1535
 
1536
+ /**
1537
+ * The schema version this package writes and expects. Named rather than
1538
+ * repeated as a literal because it is asserted in three places, and a doctor
1539
+ * check that silently lags the migration runner reports a healthy database as
1540
+ * broken.
1541
+ */
1542
+ const CURRENT_SCHEMA_VERSION = 6;
1543
+ const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5, 6];
1544
+
1038
1545
  export class SynomemClient extends SynomemCore implements SynomemService {
1039
1546
  readonly home: string;
1040
1547
  readonly storage: SynomemStorage;
@@ -1133,8 +1640,9 @@ export class SynomemClient extends SynomemCore implements SynomemService {
1133
1640
  });
1134
1641
  const migrationState = this.storage.migrationState();
1135
1642
  const migrationsValid =
1136
- migrationState.schemaVersion === 3 &&
1137
- JSON.stringify(migrationState.appliedVersions) === JSON.stringify([1, 2, 3]);
1643
+ migrationState.schemaVersion === CURRENT_SCHEMA_VERSION &&
1644
+ JSON.stringify(migrationState.appliedVersions) ===
1645
+ JSON.stringify(EXPECTED_APPLIED_MIGRATIONS);
1138
1646
  diagnostics.push({
1139
1647
  level: migrationsValid ? 'ok' : 'error',
1140
1648
  code: migrationsValid ? 'MIGRATIONS_VALID' : 'MIGRATIONS_INCONSISTENT',
@@ -1201,7 +1709,7 @@ export class SynomemClient extends SynomemCore implements SynomemService {
1201
1709
  const records = recordsFromEvents(events);
1202
1710
  const memos = memoRecordsFromEvents(events);
1203
1711
  const notes = noteRecordsFromEvents(events);
1204
- const todos = todoRecordsFromEvents(events);
1712
+ const tasks = taskRecordsFromEvents(events);
1205
1713
  const warning = scan.invalid.length
1206
1714
  ? `> Warning: ${scan.invalid.length} unsupported or malformed event(s) omitted from this Markdown view: ${scan.invalid.map((item) => item.id).join(', ')}\n\n`
1207
1715
  : '';
@@ -1218,9 +1726,9 @@ export class SynomemClient extends SynomemCore implements SynomemService {
1218
1726
  (record) =>
1219
1727
  `## Note: ${escapeMarkdown(record.current.title)}\n\n${escapeMarkdown(record.current.body)}\n\nStatus: ${record.status}; version ${record.current.version}\n\nID: \`${record.event.id}\``,
1220
1728
  ),
1221
- ...todos.map(
1729
+ ...tasks.map(
1222
1730
  (record) =>
1223
- `## Todo: ${escapeMarkdown(record.current.title)}\n\n${record.current.description ? `${escapeMarkdown(record.current.description)}\n\n` : ''}Status: ${record.status}; priority ${record.current.priority}\n\nID: \`${record.event.id}\``,
1731
+ `## Task: ${escapeMarkdown(record.current.title)}\n\n${record.current.description ? `${escapeMarkdown(record.current.description)}\n\n` : ''}Status: ${record.status}; priority ${record.current.priority}\n\nID: \`${record.event.id}\``,
1224
1732
  ),
1225
1733
  ];
1226
1734
  return `${warning}${sections.join('\n\n')}\n`;