synomem 0.1.0 → 0.2.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 -1
  3. package/README.md +28 -15
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +311 -70
  6. package/dist/cli.js.map +1 -1
  7. package/dist/client.d.ts +96 -17
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +333 -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 +207 -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 +156 -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 +18 -1
  25. package/dist/ports/repository.d.ts.map +1 -1
  26. package/dist/projections.d.ts +18 -1
  27. package/dist/projections.d.ts.map +1 -1
  28. package/dist/projections.js +137 -44
  29. package/dist/projections.js.map +1 -1
  30. package/dist/remote.d.ts +57 -9
  31. package/dist/remote.d.ts.map +1 -1
  32. package/dist/remote.js +43 -2
  33. package/dist/remote.js.map +1 -1
  34. package/dist/schemas.d.ts +302 -21
  35. package/dist/schemas.d.ts.map +1 -1
  36. package/dist/schemas.js +142 -26
  37. package/dist/schemas.js.map +1 -1
  38. package/dist/service.d.ts +57 -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 +41 -1
  45. package/dist/storage.d.ts.map +1 -1
  46. package/dist/storage.js +254 -36
  47. package/dist/storage.js.map +1 -1
  48. package/dist/types.d.ts +213 -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 +572 -173
  61. package/src/client.ts +391 -79
  62. package/src/config.ts +8 -8
  63. package/src/index.ts +11 -1
  64. package/src/mcp/index.ts +189 -30
  65. package/src/mcp-server.ts +58 -8
  66. package/src/ports/repository.ts +16 -0
  67. package/src/projections.ts +139 -44
  68. package/src/remote.ts +118 -8
  69. package/src/schemas.ts +147 -26
  70. package/src/service.ts +59 -7
  71. package/src/skill-install.ts +14 -17
  72. package/src/storage.ts +326 -34
  73. package/src/types.ts +228 -39
package/src/client.ts CHANGED
@@ -10,13 +10,16 @@ import {
10
10
  noteRecordsFromEvents,
11
11
  ProjectionManager,
12
12
  recordsFromEvents,
13
+ taskRecordsFromEvents,
13
14
  todoRecordsFromEvents,
14
15
  } from './projections.js';
15
16
  import {
16
17
  actorSchema,
17
- agentIdSchema,
18
+ agentLookupSchema,
19
+ bindRuntimeSchema,
18
20
  createAgentSchema,
19
21
  createNoteSchema,
22
+ createTaskSchema,
20
23
  createTodoSchema,
21
24
  changesInputSchema,
22
25
  giveKudosSchema,
@@ -24,6 +27,7 @@ import {
24
27
  listInputSchema,
25
28
  reviseNoteSchema,
26
29
  sendMemoSchema,
30
+ updateTaskSchema,
27
31
  updateTodoSchema,
28
32
  updateAgentSchema,
29
33
  } from './schemas.js';
@@ -39,7 +43,11 @@ import type { SynomemRepository } from './ports/repository.js';
39
43
  import type { ProjectionWriter } from './ports/projections.js';
40
44
  import type {
41
45
  ActorIdentity,
46
+ AgentDirectoryEntry,
42
47
  AgentProfile,
48
+ AgentResolution,
49
+ AgentRuntimeBinding,
50
+ BindRuntimeInput,
43
51
  CreateAgentInput,
44
52
  Diagnostic,
45
53
  DoctorResult,
@@ -52,9 +60,13 @@ import type {
52
60
  CreateNoteResult,
53
61
  ReviseNoteInput,
54
62
  NoteRecord,
63
+ CreateTaskInput,
55
64
  CreateTodoInput,
56
65
  CreateTodoResult,
66
+ CreateTaskResult,
67
+ UpdateTaskInput,
57
68
  UpdateTodoInput,
69
+ TaskRecord,
58
70
  TodoRecord,
59
71
  ItemListInput,
60
72
  ItemRecord,
@@ -101,6 +113,11 @@ export class SynomemCore implements SynomemDomainService {
101
113
  update: (id: string, changes: UpdateAgentInput) => this.updateAgent(id, changes),
102
114
  get: (idOrAlias: string) => this.getAgent(idOrAlias),
103
115
  list: () => this.listAgents(),
116
+ resolve: (query: string) => this.resolveAgent(query),
117
+ directory: () => this.agentDirectory(),
118
+ bindings: (idOrAlias: string) => this.listRuntimeBindings(idOrAlias),
119
+ bindRuntime: (input: BindRuntimeInput) => this.bindRuntime(input),
120
+ unbindRuntime: (bindingId: string) => this.unbindRuntime(bindingId),
104
121
  };
105
122
 
106
123
  readonly kudos = {
@@ -131,20 +148,74 @@ export class SynomemCore implements SynomemDomainService {
131
148
  archive: (input: { noteId: string; idempotencyKey?: string }) => this.archiveNote(input),
132
149
  };
133
150
 
151
+ readonly tasks = {
152
+ create: (input: CreateTaskInput) => this.createTask(input),
153
+ list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
154
+ this.listItems({ ...input, kinds: ['task'] }),
155
+ get: (id: string) => this.getTask(id),
156
+ update: (input: UpdateTaskInput) => this.updateTask(input),
157
+ // A response is optional when accepting and required when rejecting: a
158
+ // refusal without a reason leaves the assigner unable to act on it.
159
+ accept: (input: { taskId: string; response?: string; idempotencyKey?: string }) =>
160
+ this.acceptTask(input),
161
+ reject: (input: { taskId: string; response: string; idempotencyKey?: string }) =>
162
+ this.rejectTask(input),
163
+ complete: (input: { taskId: string; note?: string; idempotencyKey?: string }) =>
164
+ this.completeTask(input),
165
+ reopen: (input: { taskId: string; idempotencyKey?: string }) => this.reopenTask(input),
166
+ cancel: (input: { taskId: string; reason?: string; idempotencyKey?: string }) =>
167
+ this.cancelTask(input),
168
+ };
169
+
134
170
  readonly todos = {
135
171
  create: (input: CreateTodoInput) => this.createTodo(input),
136
172
  list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
137
173
  this.listItems({ ...input, kinds: ['todo'] }),
138
174
  get: (id: string) => this.getTodo(id),
139
175
  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
176
  complete: (input: { todoId: string; note?: string; idempotencyKey?: string }) =>
144
- this.completeTodo(input),
145
- reopen: (input: { todoId: string; idempotencyKey?: string }) => this.reopenTodo(input),
177
+ this.todoTransition(input, 'todo.completed'),
178
+ reopen: (input: { todoId: string; idempotencyKey?: string }) =>
179
+ this.todoTransition(input, 'todo.reopened'),
146
180
  cancel: (input: { todoId: string; reason?: string; idempotencyKey?: string }) =>
147
- this.cancelTodo(input),
181
+ this.todoTransition(input, 'todo.canceled'),
182
+ archive: (input: { todoId: string; idempotencyKey?: string }) =>
183
+ this.todoTransition(input, 'todo.archived'),
184
+ };
185
+
186
+ /**
187
+ * Unanswered and overdue discovery.
188
+ *
189
+ * The plan asks for shared work to be observable without treating runtime
190
+ * metadata as a delivery guarantee. These are query states derived from
191
+ * durable events: they say nobody has answered yet, not that the agent was
192
+ * offline, missed a notification, or lacks a capability it claimed.
193
+ */
194
+ readonly discovery = {
195
+ /**
196
+ * Tasks awaiting acceptance, unread memos, and unacknowledged kudos —
197
+ * optionally only those older than a given age or instant.
198
+ */
199
+ unanswered: (
200
+ input: Omit<ItemListInput, 'awaitingResponse' | 'pending'> & { olderThanHours?: number } = {},
201
+ ) => {
202
+ const { olderThanHours, awaitingSince, ...rest } = input;
203
+ const since =
204
+ awaitingSince ??
205
+ (olderThanHours !== undefined
206
+ ? new Date(Date.now() - olderThanHours * 3_600_000).toISOString()
207
+ : undefined);
208
+ return this.listItems({
209
+ ...rest,
210
+ awaitingResponse: true,
211
+ ...(since ? { awaitingSince: since } : {}),
212
+ });
213
+ },
214
+ /** Open work whose deadline has passed. Defaults to "now". */
215
+ overdue: (input: Omit<ItemListInput, 'overdueAsOf'> & { asOf?: string } = {}) => {
216
+ const { asOf, ...rest } = input;
217
+ return this.listItems({ ...rest, overdueAsOf: asOf ?? new Date().toISOString() });
218
+ },
148
219
  };
149
220
 
150
221
  readonly items = {
@@ -251,7 +322,7 @@ export class SynomemCore implements SynomemDomainService {
251
322
  private async updateAgent(idOrAlias: string, changes: UpdateAgentInput): Promise<AgentProfile> {
252
323
  this.checkAbort();
253
324
  await this.repository.assertEventCompatibility();
254
- this.validate(() => agentIdSchema.parse(idOrAlias));
325
+ this.validate(() => agentLookupSchema.parse(idOrAlias));
255
326
  const parsed = this.validate(() => updateAgentSchema.parse(changes));
256
327
  const existing = await this.repository.getAgent(idOrAlias);
257
328
  if (!existing) throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
@@ -287,7 +358,7 @@ export class SynomemCore implements SynomemDomainService {
287
358
 
288
359
  private async getAgent(idOrAlias: string): Promise<AgentProfile> {
289
360
  this.checkAbort();
290
- this.validate(() => agentIdSchema.parse(idOrAlias));
361
+ this.validate(() => agentLookupSchema.parse(idOrAlias));
291
362
  const profile = await this.repository.getAgent(idOrAlias);
292
363
  if (!profile) throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
293
364
  return profile;
@@ -298,6 +369,77 @@ export class SynomemCore implements SynomemDomainService {
298
369
  return await this.repository.listAgents();
299
370
  }
300
371
 
372
+ /**
373
+ * Resolves a name without ever choosing between equally valid answers.
374
+ *
375
+ * Callers that want a single agent should treat an empty `match` as a
376
+ * question for the user, not as "not found": `candidates` distinguishes the
377
+ * two cases.
378
+ */
379
+ private async resolveAgent(query: string): Promise<AgentResolution> {
380
+ this.checkAbort();
381
+ const trimmed = query.trim();
382
+ if (!trimmed) throw new SynomemError('INVALID_INPUT', 'A lookup name is required.');
383
+ const resolved = await this.repository.resolveAgent(trimmed);
384
+ return {
385
+ query: trimmed,
386
+ ...(resolved.match ? { match: resolved.match } : {}),
387
+ candidates: resolved.candidates,
388
+ };
389
+ }
390
+
391
+ private async agentDirectory(): Promise<AgentDirectoryEntry[]> {
392
+ this.checkAbort();
393
+ const profiles = await this.repository.listAgents();
394
+ const entries: AgentDirectoryEntry[] = [];
395
+ for (const profile of profiles) {
396
+ entries.push({
397
+ profile,
398
+ runtimeBindings: await this.repository.listRuntimeBindings(profile.id),
399
+ });
400
+ }
401
+ return entries;
402
+ }
403
+
404
+ private async listRuntimeBindings(idOrAlias: string): Promise<AgentRuntimeBinding[]> {
405
+ const profile = await this.getAgent(idOrAlias);
406
+ return await this.repository.listRuntimeBindings(profile.id);
407
+ }
408
+
409
+ /**
410
+ * Records where an agent runs. Re-binding the same runtime and profile
411
+ * updates the claim in place rather than accumulating duplicates, because a
412
+ * reinstall is the same agent in the same place, not a second one.
413
+ */
414
+ private async bindRuntime(input: BindRuntimeInput): Promise<AgentRuntimeBinding> {
415
+ this.checkAbort();
416
+ const parsed = this.validate(() => bindRuntimeSchema.parse(input));
417
+ const profile = await this.getAgent(parsed.agentId);
418
+ await this.repository.bindRuntime({
419
+ id: this.idGenerator(),
420
+ agentId: profile.id,
421
+ ...(parsed.installationId !== undefined ? { installationId: parsed.installationId } : {}),
422
+ runtime: parsed.runtime,
423
+ ...(parsed.profile !== undefined ? { profile: parsed.profile } : {}),
424
+ ...(parsed.capabilities !== undefined ? { capabilities: parsed.capabilities } : {}),
425
+ boundAt: this.now(),
426
+ });
427
+ const bindings = await this.repository.listRuntimeBindings(profile.id);
428
+ const binding = bindings.find(
429
+ (candidate) =>
430
+ candidate.runtime === parsed.runtime &&
431
+ (candidate.profile ?? '') === (parsed.profile ?? '') &&
432
+ (candidate.installationId ?? '') === (parsed.installationId ?? ''),
433
+ );
434
+ if (!binding) throw new SynomemError('INTERNAL_ERROR', 'Runtime binding was not persisted.');
435
+ return binding;
436
+ }
437
+
438
+ private async unbindRuntime(bindingId: string): Promise<boolean> {
439
+ this.checkAbort();
440
+ return await this.repository.unbindRuntime(bindingId);
441
+ }
442
+
301
443
  private async giveKudos(input: GiveKudosInput): Promise<GiveKudosResult> {
302
444
  this.checkAbort();
303
445
  await this.repository.assertEventCompatibility();
@@ -493,7 +635,7 @@ export class SynomemCore implements SynomemDomainService {
493
635
  ? 'MEMO_NOT_FOUND'
494
636
  : kind === 'note'
495
637
  ? 'NOTE_NOT_FOUND'
496
- : kind === 'todo'
638
+ : kind === 'task'
497
639
  ? 'TODO_NOT_FOUND'
498
640
  : 'KUDOS_NOT_FOUND';
499
641
  throw new SynomemError(code, `Unknown ${kind}: ${id}`);
@@ -744,21 +886,21 @@ export class SynomemCore implements SynomemDomainService {
744
886
  return await this.getNoteRecord(input.noteId);
745
887
  }
746
888
 
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}`);
889
+ private async getTaskRecord(id: string): Promise<TaskRecord> {
890
+ await this.requireVisibleItem(id, 'task');
891
+ const record = taskRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
892
+ if (!record) throw new SynomemError('TODO_NOT_FOUND', `Unknown task: ${id}`);
751
893
  return record;
752
894
  }
753
- private async getTodo(id: string): Promise<TodoRecord> {
895
+ private async getTask(id: string): Promise<TaskRecord> {
754
896
  this.checkAbort();
755
- return await this.getTodoRecord(id);
897
+ return await this.getTaskRecord(id);
756
898
  }
757
899
 
758
- private async createTodo(input: CreateTodoInput): Promise<CreateTodoResult> {
900
+ private async createTask(input: CreateTaskInput): Promise<CreateTaskResult> {
759
901
  this.checkAbort();
760
902
  await this.repository.assertEventCompatibility();
761
- const parsed = this.validate(() => createTodoSchema.parse(input));
903
+ const parsed = this.validate(() => createTaskSchema.parse(input));
762
904
  const assigneeId =
763
905
  parsed.assigneeAgentId ?? (this.actor.kind === 'agent' ? this.actor.id : undefined);
764
906
  if (!assigneeId)
@@ -768,21 +910,21 @@ export class SynomemCore implements SynomemDomainService {
768
910
  );
769
911
  const assignee = await this.repository.getAgent(assigneeId);
770
912
  if (!assignee)
771
- throw new SynomemError('AGENT_NOT_FOUND', `Unknown todo assignee: ${assigneeId}`);
913
+ throw new SynomemError('AGENT_NOT_FOUND', `Unknown task assignee: ${assigneeId}`);
772
914
  if (
773
915
  this.actor.kind === 'agent' &&
774
916
  this.actor.id !== assignee.id &&
775
- !this.repository.config.allowCrossAgentTodos
917
+ !this.repository.config.allowCrossAgentTasks
776
918
  )
777
- throw new SynomemError('POLICY_FORBIDDEN', 'Cross-agent todo assignment is disabled.');
919
+ throw new SynomemError('POLICY_FORBIDDEN', 'Cross-agent task assignment is disabled.');
778
920
  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 };
921
+ const prior = await this.priorMutation(parsed.idempotencyKey, 'task.created');
922
+ if (prior?.type === 'task.created') return { id: prior.id, created: false };
781
923
  const id = this.nextId();
782
924
  const requiresAcceptance = this.actor.kind !== 'agent' || this.actor.id !== assignee.id;
783
925
  const event: SynomemEvent = {
784
926
  ...this.eventBase(id, 1, id),
785
- type: 'todo.created',
927
+ type: 'task.created',
786
928
  assigneeAgentId: assignee.id,
787
929
  assigneeDisplayName: assignee.displayName,
788
930
  title: parsed.title,
@@ -801,13 +943,13 @@ export class SynomemCore implements SynomemDomainService {
801
943
  });
802
944
  if (outcome.created) await this.projectionWriter.syncAgent(assignee.id);
803
945
  return {
804
- record: await this.getTodoRecord(outcome.id),
946
+ record: await this.getTaskRecord(outcome.id),
805
947
  created: outcome.created,
806
948
  deduplicated: !outcome.created,
807
949
  };
808
950
  }
809
951
 
810
- private assertTodoParticipant(record: TodoRecord): void {
952
+ private assertTaskParticipant(record: TaskRecord): void {
811
953
  const isCreator =
812
954
  record.event.actor.kind === this.actor.kind && record.event.actor.id === this.actor.id;
813
955
  const isAssignee =
@@ -815,35 +957,35 @@ export class SynomemCore implements SynomemDomainService {
815
957
  if (!this.administrative && !isCreator && !isAssignee)
816
958
  throw new SynomemError(
817
959
  'MUTATION_FORBIDDEN',
818
- 'Only the todo creator, assignee, or a human administrator may change it.',
960
+ 'Only the task creator, assignee, or a human administrator may change it.',
819
961
  );
820
962
  }
821
963
 
822
- private assertTodoAssignee(record: TodoRecord): void {
964
+ private assertTaskAssignee(record: TaskRecord): void {
823
965
  if (
824
966
  !this.administrative &&
825
967
  !(this.actor.kind === 'agent' && this.actor.id === record.event.assigneeAgentId)
826
968
  ) {
827
969
  throw new SynomemError(
828
970
  'MUTATION_FORBIDDEN',
829
- 'Only the assigned agent or a human administrator may accept or reject this todo.',
971
+ 'Only the assigned agent or a human administrator may accept or reject this task.',
830
972
  );
831
973
  }
832
974
  }
833
975
 
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);
976
+ private async updateTask(input: UpdateTaskInput): Promise<TaskRecord> {
977
+ const parsed = this.validate(() => updateTaskSchema.parse(input));
978
+ const record = await this.getTaskRecord(parsed.taskId);
979
+ this.assertTaskParticipant(record);
838
980
  if (record.status !== 'open')
839
- throw new SynomemError('INVALID_INPUT', 'Only open todos can be updated.');
981
+ throw new SynomemError('INVALID_INPUT', 'Only open tasks can be updated.');
840
982
  if (parsed.expectedVersion !== record.current.version)
841
983
  throw new SynomemError(
842
984
  'REVISION_CONFLICT',
843
- `Expected todo version ${parsed.expectedVersion}; current version is ${record.current.version}.`,
985
+ `Expected task version ${parsed.expectedVersion}; current version is ${record.current.version}.`,
844
986
  );
845
987
  await this.repository.transaction(async () => {
846
- const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.updated');
988
+ const prior = await this.priorMutation(parsed.idempotencyKey, 'task.updated');
847
989
  if (prior) return;
848
990
  if (
849
991
  (await this.repository.nextAggregateVersion(record.event.id)) !==
@@ -851,13 +993,13 @@ export class SynomemCore implements SynomemDomainService {
851
993
  )
852
994
  throw new SynomemError(
853
995
  'REVISION_CONFLICT',
854
- 'The todo changed before this update was stored.',
996
+ 'The task changed before this update was stored.',
855
997
  );
856
998
  const due = parsed.due === null ? undefined : (parsed.due ?? record.current.due);
857
999
  const event: SynomemEvent = {
858
1000
  ...this.eventBase(record.event.id, parsed.expectedVersion + 1),
859
- type: 'todo.updated',
860
- todoId: record.event.id,
1001
+ type: 'task.updated',
1002
+ taskId: record.event.id,
861
1003
  title: parsed.title ?? record.current.title,
862
1004
  ...(parsed.description !== undefined
863
1005
  ? { description: parsed.description }
@@ -875,35 +1017,49 @@ export class SynomemCore implements SynomemDomainService {
875
1017
  await this.repository.insertEvent(event);
876
1018
  });
877
1019
  await this.projectionWriter.syncAgent(record.event.assigneeAgentId);
878
- return await this.getTodoRecord(parsed.todoId);
1020
+ return await this.getTaskRecord(parsed.taskId);
879
1021
  }
880
1022
 
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.');
1023
+ private async taskTransition(
1024
+ input: {
1025
+ taskId: string;
1026
+ idempotencyKey?: string;
1027
+ note?: string;
1028
+ reason?: string;
1029
+ response?: string;
1030
+ },
1031
+ type: 'task.accepted' | 'task.rejected' | 'task.completed' | 'task.reopened' | 'task.canceled',
1032
+ ): Promise<TaskRecord> {
1033
+ const record = await this.getTaskRecord(input.taskId);
1034
+ // A rejection must say why. Enforced here as well as in the schema so the
1035
+ // failure names the missing thing rather than surfacing as a parse error.
1036
+ if (type === 'task.rejected' && !input.response?.trim()) {
1037
+ throw new SynomemError(
1038
+ 'INVALID_INPUT',
1039
+ 'Rejecting a task requires a response explaining why, so the assigner knows whether to reassign it, wait, or change the request.',
1040
+ );
892
1041
  }
893
- if (type === 'todo.completed' && record.status !== 'open') {
1042
+ if (type === 'task.accepted' || type === 'task.rejected') this.assertTaskAssignee(record);
1043
+ else this.assertTaskParticipant(record);
1044
+ if ((type === 'task.accepted' || type === 'task.rejected') && record.status !== 'assigned') {
1045
+ if (type === 'task.accepted' && record.status === 'open') return record;
1046
+ if (type === 'task.rejected' && record.status === 'rejected') return record;
1047
+ throw new SynomemError('INVALID_INPUT', 'Only assigned tasks may be accepted or rejected.');
1048
+ }
1049
+ if (type === 'task.completed' && record.status !== 'open') {
894
1050
  if (record.status === 'completed') return record;
895
1051
  throw new SynomemError(
896
1052
  'INVALID_INPUT',
897
- 'Only accepted or self-created open todos may be completed.',
1053
+ 'Only accepted or self-created open tasks may be completed.',
898
1054
  );
899
1055
  }
900
- if (type === 'todo.reopened' && record.status !== 'completed' && record.status !== 'canceled') {
1056
+ if (type === 'task.reopened' && record.status !== 'completed' && record.status !== 'canceled') {
901
1057
  if (record.status === 'open') return record;
902
- throw new SynomemError('INVALID_INPUT', 'Only completed or canceled todos may be reopened.');
1058
+ throw new SynomemError('INVALID_INPUT', 'Only completed or canceled tasks may be reopened.');
903
1059
  }
904
- if (type === 'todo.canceled' && record.status !== 'assigned' && record.status !== 'open') {
1060
+ if (type === 'task.canceled' && record.status !== 'assigned' && record.status !== 'open') {
905
1061
  if (record.status === 'canceled') return record;
906
- throw new SynomemError('INVALID_INPUT', 'Only assigned or open todos may be canceled.');
1062
+ throw new SynomemError('INVALID_INPUT', 'Only assigned or open tasks may be canceled.');
907
1063
  }
908
1064
  await this.repository.transaction(async () => {
909
1065
  const prior = await this.priorMutation(input.idempotencyKey, type);
@@ -913,34 +1069,180 @@ export class SynomemCore implements SynomemDomainService {
913
1069
  record.event.id,
914
1070
  await this.repository.nextAggregateVersion(record.event.id),
915
1071
  ),
916
- todoId: record.event.id,
1072
+ taskId: record.event.id,
917
1073
  ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
918
1074
  };
919
1075
  const event: SynomemEvent =
920
- type === 'todo.completed'
1076
+ type === 'task.completed'
921
1077
  ? { ...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 };
1078
+ : type === 'task.rejected'
1079
+ ? { ...base, type, response: input.response!.trim() }
1080
+ : type === 'task.accepted'
1081
+ ? {
1082
+ ...base,
1083
+ type,
1084
+ ...(input.response ? { response: input.response.trim() } : {}),
1085
+ }
1086
+ : type === 'task.canceled'
1087
+ ? { ...base, type, ...(input.reason ? { reason: input.reason.trim() } : {}) }
1088
+ : { ...base, type };
925
1089
  await this.repository.insertEvent(event);
926
1090
  });
927
1091
  await this.projectionWriter.syncAgent(record.event.assigneeAgentId);
928
- return await this.getTodoRecord(input.todoId);
1092
+ return await this.getTaskRecord(input.taskId);
929
1093
  }
930
- private completeTodo(input: { todoId: string; note?: string; idempotencyKey?: string }) {
931
- return this.todoTransition(input, 'todo.completed');
1094
+ private completeTask(input: { taskId: string; note?: string; idempotencyKey?: string }) {
1095
+ return this.taskTransition(input, 'task.completed');
932
1096
  }
933
- private acceptTodo(input: { todoId: string; idempotencyKey?: string }) {
934
- return this.todoTransition(input, 'todo.accepted');
1097
+ private acceptTask(input: { taskId: string; response?: string; idempotencyKey?: string }) {
1098
+ return this.taskTransition(input, 'task.accepted');
935
1099
  }
936
- private rejectTodo(input: { todoId: string; reason?: string; idempotencyKey?: string }) {
937
- return this.todoTransition(input, 'todo.rejected');
1100
+ private rejectTask(input: { taskId: string; response: string; idempotencyKey?: string }) {
1101
+ return this.taskTransition(input, 'task.rejected');
938
1102
  }
939
- private reopenTodo(input: { todoId: string; idempotencyKey?: string }) {
940
- return this.todoTransition(input, 'todo.reopened');
1103
+ private reopenTask(input: { taskId: string; idempotencyKey?: string }) {
1104
+ return this.taskTransition(input, 'task.reopened');
941
1105
  }
942
- private cancelTodo(input: { todoId: string; reason?: string; idempotencyKey?: string }) {
943
- return this.todoTransition(input, 'todo.canceled');
1106
+ private cancelTask(input: { taskId: string; reason?: string; idempotencyKey?: string }) {
1107
+ return this.taskTransition(input, 'task.canceled');
1108
+ }
1109
+
1110
+ /* ----------------------------------------------------------------- todos *
1111
+ * A Todo is a private reminder an agent creates for itself. There is no
1112
+ * assignee, no acceptance, and no visibility choice: it belongs to its author
1113
+ * and only its author reads it. Every method below asserts that ownership
1114
+ * rather than relying on a visibility filter, so a Todo cannot be reached by
1115
+ * guessing its id.
1116
+ */
1117
+
1118
+ private async createTodo(input: CreateTodoInput): Promise<CreateTodoResult> {
1119
+ this.checkAbort();
1120
+ await this.repository.assertEventCompatibility();
1121
+ const parsed = this.validate(() => createTodoSchema.parse(input));
1122
+ const outcome = await this.repository.transaction(async () => {
1123
+ const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.created');
1124
+ if (prior?.type === 'todo.created') return { id: prior.id, created: false };
1125
+ const id = this.nextId();
1126
+ const event: SynomemEvent = {
1127
+ ...this.eventBase(id, 1, id),
1128
+ type: 'todo.created',
1129
+ title: parsed.title,
1130
+ ...(parsed.details !== undefined ? { details: parsed.details } : {}),
1131
+ priority: parsed.priority ?? 3,
1132
+ ...(parsed.due ? { due: parsed.due } : {}),
1133
+ tags: [...new Set(parsed.tags ?? [])].sort(),
1134
+ ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
1135
+ ...(parsed.source ? { source: parsed.source } : {}),
1136
+ ...(parsed.metadata ? { metadata: parsed.metadata } : {}),
1137
+ };
1138
+ await this.repository.insertEvent(event);
1139
+ return { id, created: true };
1140
+ });
1141
+ return {
1142
+ record: await this.getTodoRecord(outcome.id),
1143
+ created: outcome.created,
1144
+ deduplicated: !outcome.created,
1145
+ };
1146
+ }
1147
+
1148
+ private async getTodoRecord(id: string): Promise<TodoRecord> {
1149
+ const record = todoRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
1150
+ if (!record) throw new SynomemError('ITEM_NOT_FOUND', `Unknown todo: ${id}`);
1151
+ this.assertTodoOwner(record);
1152
+ return record;
1153
+ }
1154
+
1155
+ /**
1156
+ * Todos are owner-only. No role, however broad, reads another actor's
1157
+ * private reminders — that is a named administrative capability this release
1158
+ * does not have, not something a permission check quietly allows.
1159
+ */
1160
+ private assertTodoOwner(record: TodoRecord): void {
1161
+ const owner = record.event.actor;
1162
+ if (owner.kind !== this.actor.kind || owner.id !== this.actor.id) {
1163
+ throw new SynomemError(
1164
+ 'MUTATION_FORBIDDEN',
1165
+ 'A todo is private to the actor who created it.',
1166
+ );
1167
+ }
1168
+ }
1169
+
1170
+ private async getTodo(id: string): Promise<TodoRecord> {
1171
+ this.checkAbort();
1172
+ return await this.getTodoRecord(id);
1173
+ }
1174
+
1175
+ private async updateTodo(input: UpdateTodoInput): Promise<TodoRecord> {
1176
+ this.checkAbort();
1177
+ const parsed = this.validate(() => updateTodoSchema.parse(input));
1178
+ const record = await this.getTodoRecord(parsed.todoId);
1179
+ if (record.current.version !== parsed.expectedVersion) {
1180
+ throw new SynomemError(
1181
+ 'REVISION_CONFLICT',
1182
+ `Todo ${parsed.todoId} is at version ${record.current.version}.`,
1183
+ );
1184
+ }
1185
+ const due = parsed.due === null ? undefined : (parsed.due ?? record.current.due);
1186
+ await this.repository.transaction(async () => {
1187
+ const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.updated');
1188
+ if (prior) return;
1189
+ const event: SynomemEvent = {
1190
+ ...this.eventBase(
1191
+ record.event.id,
1192
+ await this.repository.nextAggregateVersion(record.event.id),
1193
+ ),
1194
+ type: 'todo.updated',
1195
+ todoId: record.event.id,
1196
+ title: parsed.title ?? record.current.title,
1197
+ ...((parsed.details ?? record.current.details)
1198
+ ? { details: parsed.details ?? record.current.details }
1199
+ : {}),
1200
+ priority: parsed.priority ?? record.current.priority,
1201
+ ...(due ? { due } : {}),
1202
+ tags: [...new Set<string>(parsed.tags ?? record.current.tags)].sort(),
1203
+ ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
1204
+ };
1205
+ await this.repository.insertEvent(event);
1206
+ });
1207
+ return await this.getTodoRecord(parsed.todoId);
1208
+ }
1209
+
1210
+ private async todoTransition(
1211
+ input: { todoId: string; idempotencyKey?: string; note?: string; reason?: string },
1212
+ type: 'todo.completed' | 'todo.reopened' | 'todo.canceled' | 'todo.archived',
1213
+ ): Promise<TodoRecord> {
1214
+ const record = await this.getTodoRecord(input.todoId);
1215
+ if (type === 'todo.completed' && record.status !== 'open') {
1216
+ if (record.status === 'completed') return record;
1217
+ throw new SynomemError('INVALID_INPUT', 'Only an open todo may be completed.');
1218
+ }
1219
+ if (type === 'todo.reopened' && record.status === 'open') return record;
1220
+ if (type === 'todo.canceled' && record.status !== 'open') {
1221
+ if (record.status === 'canceled') return record;
1222
+ throw new SynomemError('INVALID_INPUT', 'Only an open todo may be canceled.');
1223
+ }
1224
+ if (type === 'todo.archived' && record.status === 'archived') return record;
1225
+
1226
+ await this.repository.transaction(async () => {
1227
+ const prior = await this.priorMutation(input.idempotencyKey, type);
1228
+ if (prior) return;
1229
+ const base = {
1230
+ ...this.eventBase(
1231
+ record.event.id,
1232
+ await this.repository.nextAggregateVersion(record.event.id),
1233
+ ),
1234
+ todoId: record.event.id,
1235
+ ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
1236
+ };
1237
+ const event: SynomemEvent =
1238
+ type === 'todo.completed'
1239
+ ? { ...base, type, ...(input.note ? { note: input.note.trim() } : {}) }
1240
+ : type === 'todo.canceled'
1241
+ ? { ...base, type, ...(input.reason ? { reason: input.reason.trim() } : {}) }
1242
+ : { ...base, type };
1243
+ await this.repository.insertEvent(event);
1244
+ });
1245
+ return await this.getTodoRecord(input.todoId);
944
1246
  }
945
1247
 
946
1248
  private async listItems(input: ItemListInput): Promise<Page<ItemSummary>> {
@@ -976,7 +1278,7 @@ export class SynomemCore implements SynomemDomainService {
976
1278
  if (summary.kind === 'kudos') return this.getKudos(id);
977
1279
  if (summary.kind === 'memo') return this.getMemo(id);
978
1280
  if (summary.kind === 'note') return this.getNote(id);
979
- return this.getTodo(id);
1281
+ return this.getTask(id);
980
1282
  }
981
1283
 
982
1284
  async stats(input: KudosListInput = {}): Promise<KudosStats> {
@@ -1035,6 +1337,15 @@ export class SynomemCore implements SynomemDomainService {
1035
1337
  }
1036
1338
  }
1037
1339
 
1340
+ /**
1341
+ * The schema version this package writes and expects. Named rather than
1342
+ * repeated as a literal because it is asserted in three places, and a doctor
1343
+ * check that silently lags the migration runner reports a healthy database as
1344
+ * broken.
1345
+ */
1346
+ const CURRENT_SCHEMA_VERSION = 5;
1347
+ const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5];
1348
+
1038
1349
  export class SynomemClient extends SynomemCore implements SynomemService {
1039
1350
  readonly home: string;
1040
1351
  readonly storage: SynomemStorage;
@@ -1133,8 +1444,9 @@ export class SynomemClient extends SynomemCore implements SynomemService {
1133
1444
  });
1134
1445
  const migrationState = this.storage.migrationState();
1135
1446
  const migrationsValid =
1136
- migrationState.schemaVersion === 3 &&
1137
- JSON.stringify(migrationState.appliedVersions) === JSON.stringify([1, 2, 3]);
1447
+ migrationState.schemaVersion === CURRENT_SCHEMA_VERSION &&
1448
+ JSON.stringify(migrationState.appliedVersions) ===
1449
+ JSON.stringify(EXPECTED_APPLIED_MIGRATIONS);
1138
1450
  diagnostics.push({
1139
1451
  level: migrationsValid ? 'ok' : 'error',
1140
1452
  code: migrationsValid ? 'MIGRATIONS_VALID' : 'MIGRATIONS_INCONSISTENT',
@@ -1201,7 +1513,7 @@ export class SynomemClient extends SynomemCore implements SynomemService {
1201
1513
  const records = recordsFromEvents(events);
1202
1514
  const memos = memoRecordsFromEvents(events);
1203
1515
  const notes = noteRecordsFromEvents(events);
1204
- const todos = todoRecordsFromEvents(events);
1516
+ const tasks = taskRecordsFromEvents(events);
1205
1517
  const warning = scan.invalid.length
1206
1518
  ? `> Warning: ${scan.invalid.length} unsupported or malformed event(s) omitted from this Markdown view: ${scan.invalid.map((item) => item.id).join(', ')}\n\n`
1207
1519
  : '';
@@ -1218,9 +1530,9 @@ export class SynomemClient extends SynomemCore implements SynomemService {
1218
1530
  (record) =>
1219
1531
  `## 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
1532
  ),
1221
- ...todos.map(
1533
+ ...tasks.map(
1222
1534
  (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}\``,
1535
+ `## 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
1536
  ),
1225
1537
  ];
1226
1538
  return `${warning}${sections.join('\n\n')}\n`;