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/dist/client.js CHANGED
@@ -4,8 +4,8 @@ import { ulid } from 'ulid';
4
4
  import { resolveHome } from './config.js';
5
5
  import { asSynomemError, SynomemError } from './errors.js';
6
6
  import { assertNoSymlinkEscape } from './fs-utils.js';
7
- import { escapeMarkdown, memoRecordsFromEvents, noteRecordsFromEvents, ProjectionManager, recordsFromEvents, todoRecordsFromEvents, } from './projections.js';
8
- import { actorSchema, agentIdSchema, createAgentSchema, createNoteSchema, createTodoSchema, changesInputSchema, giveKudosSchema, itemListInputSchema, listInputSchema, reviseNoteSchema, sendMemoSchema, updateTodoSchema, updateAgentSchema, } from './schemas.js';
7
+ import { escapeMarkdown, memoRecordsFromEvents, noteRecordsFromEvents, ProjectionManager, recordsFromEvents, taskRecordsFromEvents, todoRecordsFromEvents, } from './projections.js';
8
+ import { actorSchema, agentLookupSchema, bindRuntimeSchema, createAgentSchema, createNoteSchema, createTaskSchema, createTodoSchema, changesInputSchema, giveKudosSchema, itemListInputSchema, listInputSchema, reviseNoteSchema, sendMemoSchema, updateTaskSchema, updateTodoSchema, updateAgentSchema, } from './schemas.js';
9
9
  import { SynomemStorage } from './storage.js';
10
10
  export class SynomemCore {
11
11
  actor;
@@ -21,6 +21,11 @@ export class SynomemCore {
21
21
  update: (id, changes) => this.updateAgent(id, changes),
22
22
  get: (idOrAlias) => this.getAgent(idOrAlias),
23
23
  list: () => this.listAgents(),
24
+ resolve: (query) => this.resolveAgent(query),
25
+ directory: () => this.agentDirectory(),
26
+ bindings: (idOrAlias) => this.listRuntimeBindings(idOrAlias),
27
+ bindRuntime: (input) => this.bindRuntime(input),
28
+ unbindRuntime: (bindingId) => this.unbindRuntime(bindingId),
24
29
  };
25
30
  kudos = {
26
31
  give: (input) => this.giveKudos(input),
@@ -44,16 +49,59 @@ export class SynomemCore {
44
49
  revise: (input) => this.reviseNote(input),
45
50
  archive: (input) => this.archiveNote(input),
46
51
  };
52
+ tasks = {
53
+ create: (input) => this.createTask(input),
54
+ list: (input = {}) => this.listItems({ ...input, kinds: ['task'] }),
55
+ get: (id) => this.getTask(id),
56
+ update: (input) => this.updateTask(input),
57
+ // A response is optional when accepting and required when rejecting: a
58
+ // refusal without a reason leaves the assigner unable to act on it.
59
+ accept: (input) => this.acceptTask(input),
60
+ reject: (input) => this.rejectTask(input),
61
+ complete: (input) => this.completeTask(input),
62
+ reopen: (input) => this.reopenTask(input),
63
+ cancel: (input) => this.cancelTask(input),
64
+ };
47
65
  todos = {
48
66
  create: (input) => this.createTodo(input),
49
67
  list: (input = {}) => this.listItems({ ...input, kinds: ['todo'] }),
50
68
  get: (id) => this.getTodo(id),
51
69
  update: (input) => this.updateTodo(input),
52
- accept: (input) => this.acceptTodo(input),
53
- reject: (input) => this.rejectTodo(input),
54
- complete: (input) => this.completeTodo(input),
55
- reopen: (input) => this.reopenTodo(input),
56
- cancel: (input) => this.cancelTodo(input),
70
+ complete: (input) => this.todoTransition(input, 'todo.completed'),
71
+ reopen: (input) => this.todoTransition(input, 'todo.reopened'),
72
+ cancel: (input) => this.todoTransition(input, 'todo.canceled'),
73
+ archive: (input) => this.todoTransition(input, 'todo.archived'),
74
+ };
75
+ /**
76
+ * Unanswered and overdue discovery.
77
+ *
78
+ * The plan asks for shared work to be observable without treating runtime
79
+ * metadata as a delivery guarantee. These are query states derived from
80
+ * durable events: they say nobody has answered yet, not that the agent was
81
+ * offline, missed a notification, or lacks a capability it claimed.
82
+ */
83
+ discovery = {
84
+ /**
85
+ * Tasks awaiting acceptance, unread memos, and unacknowledged kudos —
86
+ * optionally only those older than a given age or instant.
87
+ */
88
+ unanswered: (input = {}) => {
89
+ const { olderThanHours, awaitingSince, ...rest } = input;
90
+ const since = awaitingSince ??
91
+ (olderThanHours !== undefined
92
+ ? new Date(Date.now() - olderThanHours * 3_600_000).toISOString()
93
+ : undefined);
94
+ return this.listItems({
95
+ ...rest,
96
+ awaitingResponse: true,
97
+ ...(since ? { awaitingSince: since } : {}),
98
+ });
99
+ },
100
+ /** Open work whose deadline has passed. Defaults to "now". */
101
+ overdue: (input = {}) => {
102
+ const { asOf, ...rest } = input;
103
+ return this.listItems({ ...rest, overdueAsOf: asOf ?? new Date().toISOString() });
104
+ },
57
105
  };
58
106
  items = {
59
107
  list: (input = {}) => this.listItems(input),
@@ -152,7 +200,7 @@ export class SynomemCore {
152
200
  async updateAgent(idOrAlias, changes) {
153
201
  this.checkAbort();
154
202
  await this.repository.assertEventCompatibility();
155
- this.validate(() => agentIdSchema.parse(idOrAlias));
203
+ this.validate(() => agentLookupSchema.parse(idOrAlias));
156
204
  const parsed = this.validate(() => updateAgentSchema.parse(changes));
157
205
  const existing = await this.repository.getAgent(idOrAlias);
158
206
  if (!existing)
@@ -188,7 +236,7 @@ export class SynomemCore {
188
236
  }
189
237
  async getAgent(idOrAlias) {
190
238
  this.checkAbort();
191
- this.validate(() => agentIdSchema.parse(idOrAlias));
239
+ this.validate(() => agentLookupSchema.parse(idOrAlias));
192
240
  const profile = await this.repository.getAgent(idOrAlias);
193
241
  if (!profile)
194
242
  throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
@@ -198,6 +246,71 @@ export class SynomemCore {
198
246
  this.checkAbort();
199
247
  return await this.repository.listAgents();
200
248
  }
249
+ /**
250
+ * Resolves a name without ever choosing between equally valid answers.
251
+ *
252
+ * Callers that want a single agent should treat an empty `match` as a
253
+ * question for the user, not as "not found": `candidates` distinguishes the
254
+ * two cases.
255
+ */
256
+ async resolveAgent(query) {
257
+ this.checkAbort();
258
+ const trimmed = query.trim();
259
+ if (!trimmed)
260
+ throw new SynomemError('INVALID_INPUT', 'A lookup name is required.');
261
+ const resolved = await this.repository.resolveAgent(trimmed);
262
+ return {
263
+ query: trimmed,
264
+ ...(resolved.match ? { match: resolved.match } : {}),
265
+ candidates: resolved.candidates,
266
+ };
267
+ }
268
+ async agentDirectory() {
269
+ this.checkAbort();
270
+ const profiles = await this.repository.listAgents();
271
+ const entries = [];
272
+ for (const profile of profiles) {
273
+ entries.push({
274
+ profile,
275
+ runtimeBindings: await this.repository.listRuntimeBindings(profile.id),
276
+ });
277
+ }
278
+ return entries;
279
+ }
280
+ async listRuntimeBindings(idOrAlias) {
281
+ const profile = await this.getAgent(idOrAlias);
282
+ return await this.repository.listRuntimeBindings(profile.id);
283
+ }
284
+ /**
285
+ * Records where an agent runs. Re-binding the same runtime and profile
286
+ * updates the claim in place rather than accumulating duplicates, because a
287
+ * reinstall is the same agent in the same place, not a second one.
288
+ */
289
+ async bindRuntime(input) {
290
+ this.checkAbort();
291
+ const parsed = this.validate(() => bindRuntimeSchema.parse(input));
292
+ const profile = await this.getAgent(parsed.agentId);
293
+ await this.repository.bindRuntime({
294
+ id: this.idGenerator(),
295
+ agentId: profile.id,
296
+ ...(parsed.installationId !== undefined ? { installationId: parsed.installationId } : {}),
297
+ runtime: parsed.runtime,
298
+ ...(parsed.profile !== undefined ? { profile: parsed.profile } : {}),
299
+ ...(parsed.capabilities !== undefined ? { capabilities: parsed.capabilities } : {}),
300
+ boundAt: this.now(),
301
+ });
302
+ const bindings = await this.repository.listRuntimeBindings(profile.id);
303
+ const binding = bindings.find((candidate) => candidate.runtime === parsed.runtime &&
304
+ (candidate.profile ?? '') === (parsed.profile ?? '') &&
305
+ (candidate.installationId ?? '') === (parsed.installationId ?? ''));
306
+ if (!binding)
307
+ throw new SynomemError('INTERNAL_ERROR', 'Runtime binding was not persisted.');
308
+ return binding;
309
+ }
310
+ async unbindRuntime(bindingId) {
311
+ this.checkAbort();
312
+ return await this.repository.unbindRuntime(bindingId);
313
+ }
201
314
  async giveKudos(input) {
202
315
  this.checkAbort();
203
316
  await this.repository.assertEventCompatibility();
@@ -351,7 +464,7 @@ export class SynomemCore {
351
464
  ? 'MEMO_NOT_FOUND'
352
465
  : kind === 'note'
353
466
  ? 'NOTE_NOT_FOUND'
354
- : kind === 'todo'
467
+ : kind === 'task'
355
468
  ? 'TODO_NOT_FOUND'
356
469
  : 'KUDOS_NOT_FOUND';
357
470
  throw new SynomemError(code, `Unknown ${kind}: ${id}`);
@@ -567,40 +680,40 @@ export class SynomemCore {
567
680
  await this.projectionWriter.syncAgent(record.event.ownerAgentId);
568
681
  return await this.getNoteRecord(input.noteId);
569
682
  }
570
- async getTodoRecord(id) {
571
- await this.requireVisibleItem(id, 'todo');
572
- const record = todoRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
683
+ async getTaskRecord(id) {
684
+ await this.requireVisibleItem(id, 'task');
685
+ const record = taskRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
573
686
  if (!record)
574
- throw new SynomemError('TODO_NOT_FOUND', `Unknown todo: ${id}`);
687
+ throw new SynomemError('TODO_NOT_FOUND', `Unknown task: ${id}`);
575
688
  return record;
576
689
  }
577
- async getTodo(id) {
690
+ async getTask(id) {
578
691
  this.checkAbort();
579
- return await this.getTodoRecord(id);
692
+ return await this.getTaskRecord(id);
580
693
  }
581
- async createTodo(input) {
694
+ async createTask(input) {
582
695
  this.checkAbort();
583
696
  await this.repository.assertEventCompatibility();
584
- const parsed = this.validate(() => createTodoSchema.parse(input));
697
+ const parsed = this.validate(() => createTaskSchema.parse(input));
585
698
  const assigneeId = parsed.assigneeAgentId ?? (this.actor.kind === 'agent' ? this.actor.id : undefined);
586
699
  if (!assigneeId)
587
700
  throw new SynomemError('INVALID_INPUT', 'A human or system actor must specify assigneeAgentId.');
588
701
  const assignee = await this.repository.getAgent(assigneeId);
589
702
  if (!assignee)
590
- throw new SynomemError('AGENT_NOT_FOUND', `Unknown todo assignee: ${assigneeId}`);
703
+ throw new SynomemError('AGENT_NOT_FOUND', `Unknown task assignee: ${assigneeId}`);
591
704
  if (this.actor.kind === 'agent' &&
592
705
  this.actor.id !== assignee.id &&
593
- !this.repository.config.allowCrossAgentTodos)
594
- throw new SynomemError('POLICY_FORBIDDEN', 'Cross-agent todo assignment is disabled.');
706
+ !this.repository.config.allowCrossAgentTasks)
707
+ throw new SynomemError('POLICY_FORBIDDEN', 'Cross-agent task assignment is disabled.');
595
708
  const outcome = await this.repository.transaction(async () => {
596
- const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.created');
597
- if (prior?.type === 'todo.created')
709
+ const prior = await this.priorMutation(parsed.idempotencyKey, 'task.created');
710
+ if (prior?.type === 'task.created')
598
711
  return { id: prior.id, created: false };
599
712
  const id = this.nextId();
600
713
  const requiresAcceptance = this.actor.kind !== 'agent' || this.actor.id !== assignee.id;
601
714
  const event = {
602
715
  ...this.eventBase(id, 1, id),
603
- type: 'todo.created',
716
+ type: 'task.created',
604
717
  assigneeAgentId: assignee.id,
605
718
  assigneeDisplayName: assignee.displayName,
606
719
  title: parsed.title,
@@ -620,43 +733,43 @@ export class SynomemCore {
620
733
  if (outcome.created)
621
734
  await this.projectionWriter.syncAgent(assignee.id);
622
735
  return {
623
- record: await this.getTodoRecord(outcome.id),
736
+ record: await this.getTaskRecord(outcome.id),
624
737
  created: outcome.created,
625
738
  deduplicated: !outcome.created,
626
739
  };
627
740
  }
628
- assertTodoParticipant(record) {
741
+ assertTaskParticipant(record) {
629
742
  const isCreator = record.event.actor.kind === this.actor.kind && record.event.actor.id === this.actor.id;
630
743
  const isAssignee = this.actor.kind === 'agent' && this.actor.id === record.event.assigneeAgentId;
631
744
  if (!this.administrative && !isCreator && !isAssignee)
632
- throw new SynomemError('MUTATION_FORBIDDEN', 'Only the todo creator, assignee, or a human administrator may change it.');
745
+ throw new SynomemError('MUTATION_FORBIDDEN', 'Only the task creator, assignee, or a human administrator may change it.');
633
746
  }
634
- assertTodoAssignee(record) {
747
+ assertTaskAssignee(record) {
635
748
  if (!this.administrative &&
636
749
  !(this.actor.kind === 'agent' && this.actor.id === record.event.assigneeAgentId)) {
637
- throw new SynomemError('MUTATION_FORBIDDEN', 'Only the assigned agent or a human administrator may accept or reject this todo.');
750
+ throw new SynomemError('MUTATION_FORBIDDEN', 'Only the assigned agent or a human administrator may accept or reject this task.');
638
751
  }
639
752
  }
640
- async updateTodo(input) {
641
- const parsed = this.validate(() => updateTodoSchema.parse(input));
642
- const record = await this.getTodoRecord(parsed.todoId);
643
- this.assertTodoParticipant(record);
753
+ async updateTask(input) {
754
+ const parsed = this.validate(() => updateTaskSchema.parse(input));
755
+ const record = await this.getTaskRecord(parsed.taskId);
756
+ this.assertTaskParticipant(record);
644
757
  if (record.status !== 'open')
645
- throw new SynomemError('INVALID_INPUT', 'Only open todos can be updated.');
758
+ throw new SynomemError('INVALID_INPUT', 'Only open tasks can be updated.');
646
759
  if (parsed.expectedVersion !== record.current.version)
647
- throw new SynomemError('REVISION_CONFLICT', `Expected todo version ${parsed.expectedVersion}; current version is ${record.current.version}.`);
760
+ throw new SynomemError('REVISION_CONFLICT', `Expected task version ${parsed.expectedVersion}; current version is ${record.current.version}.`);
648
761
  await this.repository.transaction(async () => {
649
- const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.updated');
762
+ const prior = await this.priorMutation(parsed.idempotencyKey, 'task.updated');
650
763
  if (prior)
651
764
  return;
652
765
  if ((await this.repository.nextAggregateVersion(record.event.id)) !==
653
766
  parsed.expectedVersion + 1)
654
- throw new SynomemError('REVISION_CONFLICT', 'The todo changed before this update was stored.');
767
+ throw new SynomemError('REVISION_CONFLICT', 'The task changed before this update was stored.');
655
768
  const due = parsed.due === null ? undefined : (parsed.due ?? record.current.due);
656
769
  const event = {
657
770
  ...this.eventBase(record.event.id, parsed.expectedVersion + 1),
658
- type: 'todo.updated',
659
- todoId: record.event.id,
771
+ type: 'task.updated',
772
+ taskId: record.event.id,
660
773
  title: parsed.title ?? record.current.title,
661
774
  ...(parsed.description !== undefined
662
775
  ? { description: parsed.description }
@@ -674,35 +787,40 @@ export class SynomemCore {
674
787
  await this.repository.insertEvent(event);
675
788
  });
676
789
  await this.projectionWriter.syncAgent(record.event.assigneeAgentId);
677
- return await this.getTodoRecord(parsed.todoId);
790
+ return await this.getTaskRecord(parsed.taskId);
678
791
  }
679
- async todoTransition(input, type) {
680
- const record = await this.getTodoRecord(input.todoId);
681
- if (type === 'todo.accepted' || type === 'todo.rejected')
682
- this.assertTodoAssignee(record);
792
+ async taskTransition(input, type) {
793
+ const record = await this.getTaskRecord(input.taskId);
794
+ // A rejection must say why. Enforced here as well as in the schema so the
795
+ // failure names the missing thing rather than surfacing as a parse error.
796
+ if (type === 'task.rejected' && !input.response?.trim()) {
797
+ throw new SynomemError('INVALID_INPUT', 'Rejecting a task requires a response explaining why, so the assigner knows whether to reassign it, wait, or change the request.');
798
+ }
799
+ if (type === 'task.accepted' || type === 'task.rejected')
800
+ this.assertTaskAssignee(record);
683
801
  else
684
- this.assertTodoParticipant(record);
685
- if ((type === 'todo.accepted' || type === 'todo.rejected') && record.status !== 'assigned') {
686
- if (type === 'todo.accepted' && record.status === 'open')
802
+ this.assertTaskParticipant(record);
803
+ if ((type === 'task.accepted' || type === 'task.rejected') && record.status !== 'assigned') {
804
+ if (type === 'task.accepted' && record.status === 'open')
687
805
  return record;
688
- if (type === 'todo.rejected' && record.status === 'rejected')
806
+ if (type === 'task.rejected' && record.status === 'rejected')
689
807
  return record;
690
- throw new SynomemError('INVALID_INPUT', 'Only assigned todos may be accepted or rejected.');
808
+ throw new SynomemError('INVALID_INPUT', 'Only assigned tasks may be accepted or rejected.');
691
809
  }
692
- if (type === 'todo.completed' && record.status !== 'open') {
810
+ if (type === 'task.completed' && record.status !== 'open') {
693
811
  if (record.status === 'completed')
694
812
  return record;
695
- throw new SynomemError('INVALID_INPUT', 'Only accepted or self-created open todos may be completed.');
813
+ throw new SynomemError('INVALID_INPUT', 'Only accepted or self-created open tasks may be completed.');
696
814
  }
697
- if (type === 'todo.reopened' && record.status !== 'completed' && record.status !== 'canceled') {
815
+ if (type === 'task.reopened' && record.status !== 'completed' && record.status !== 'canceled') {
698
816
  if (record.status === 'open')
699
817
  return record;
700
- throw new SynomemError('INVALID_INPUT', 'Only completed or canceled todos may be reopened.');
818
+ throw new SynomemError('INVALID_INPUT', 'Only completed or canceled tasks may be reopened.');
701
819
  }
702
- if (type === 'todo.canceled' && record.status !== 'assigned' && record.status !== 'open') {
820
+ if (type === 'task.canceled' && record.status !== 'assigned' && record.status !== 'open') {
703
821
  if (record.status === 'canceled')
704
822
  return record;
705
- throw new SynomemError('INVALID_INPUT', 'Only assigned or open todos may be canceled.');
823
+ throw new SynomemError('INVALID_INPUT', 'Only assigned or open tasks may be canceled.');
706
824
  }
707
825
  await this.repository.transaction(async () => {
708
826
  const prior = await this.priorMutation(input.idempotencyKey, type);
@@ -710,33 +828,163 @@ export class SynomemCore {
710
828
  return;
711
829
  const base = {
712
830
  ...this.eventBase(record.event.id, await this.repository.nextAggregateVersion(record.event.id)),
713
- todoId: record.event.id,
831
+ taskId: record.event.id,
714
832
  ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
715
833
  };
716
- const event = type === 'todo.completed'
834
+ const event = type === 'task.completed'
717
835
  ? { ...base, type, ...(input.note ? { note: input.note.trim() } : {}) }
718
- : type === 'todo.canceled' || type === 'todo.rejected'
719
- ? { ...base, type, ...(input.reason ? { reason: input.reason.trim() } : {}) }
720
- : { ...base, type };
836
+ : type === 'task.rejected'
837
+ ? { ...base, type, response: input.response.trim() }
838
+ : type === 'task.accepted'
839
+ ? {
840
+ ...base,
841
+ type,
842
+ ...(input.response ? { response: input.response.trim() } : {}),
843
+ }
844
+ : type === 'task.canceled'
845
+ ? { ...base, type, ...(input.reason ? { reason: input.reason.trim() } : {}) }
846
+ : { ...base, type };
721
847
  await this.repository.insertEvent(event);
722
848
  });
723
849
  await this.projectionWriter.syncAgent(record.event.assigneeAgentId);
724
- return await this.getTodoRecord(input.todoId);
850
+ return await this.getTaskRecord(input.taskId);
725
851
  }
726
- completeTodo(input) {
727
- return this.todoTransition(input, 'todo.completed');
852
+ completeTask(input) {
853
+ return this.taskTransition(input, 'task.completed');
728
854
  }
729
- acceptTodo(input) {
730
- return this.todoTransition(input, 'todo.accepted');
855
+ acceptTask(input) {
856
+ return this.taskTransition(input, 'task.accepted');
731
857
  }
732
- rejectTodo(input) {
733
- return this.todoTransition(input, 'todo.rejected');
858
+ rejectTask(input) {
859
+ return this.taskTransition(input, 'task.rejected');
734
860
  }
735
- reopenTodo(input) {
736
- return this.todoTransition(input, 'todo.reopened');
861
+ reopenTask(input) {
862
+ return this.taskTransition(input, 'task.reopened');
737
863
  }
738
- cancelTodo(input) {
739
- return this.todoTransition(input, 'todo.canceled');
864
+ cancelTask(input) {
865
+ return this.taskTransition(input, 'task.canceled');
866
+ }
867
+ /* ----------------------------------------------------------------- todos *
868
+ * A Todo is a private reminder an agent creates for itself. There is no
869
+ * assignee, no acceptance, and no visibility choice: it belongs to its author
870
+ * and only its author reads it. Every method below asserts that ownership
871
+ * rather than relying on a visibility filter, so a Todo cannot be reached by
872
+ * guessing its id.
873
+ */
874
+ async createTodo(input) {
875
+ this.checkAbort();
876
+ await this.repository.assertEventCompatibility();
877
+ const parsed = this.validate(() => createTodoSchema.parse(input));
878
+ const outcome = await this.repository.transaction(async () => {
879
+ const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.created');
880
+ if (prior?.type === 'todo.created')
881
+ return { id: prior.id, created: false };
882
+ const id = this.nextId();
883
+ const event = {
884
+ ...this.eventBase(id, 1, id),
885
+ type: 'todo.created',
886
+ title: parsed.title,
887
+ ...(parsed.details !== undefined ? { details: parsed.details } : {}),
888
+ priority: parsed.priority ?? 3,
889
+ ...(parsed.due ? { due: parsed.due } : {}),
890
+ tags: [...new Set(parsed.tags ?? [])].sort(),
891
+ ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
892
+ ...(parsed.source ? { source: parsed.source } : {}),
893
+ ...(parsed.metadata ? { metadata: parsed.metadata } : {}),
894
+ };
895
+ await this.repository.insertEvent(event);
896
+ return { id, created: true };
897
+ });
898
+ return {
899
+ record: await this.getTodoRecord(outcome.id),
900
+ created: outcome.created,
901
+ deduplicated: !outcome.created,
902
+ };
903
+ }
904
+ async getTodoRecord(id) {
905
+ const record = todoRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
906
+ if (!record)
907
+ throw new SynomemError('ITEM_NOT_FOUND', `Unknown todo: ${id}`);
908
+ this.assertTodoOwner(record);
909
+ return record;
910
+ }
911
+ /**
912
+ * Todos are owner-only. No role, however broad, reads another actor's
913
+ * private reminders — that is a named administrative capability this release
914
+ * does not have, not something a permission check quietly allows.
915
+ */
916
+ assertTodoOwner(record) {
917
+ const owner = record.event.actor;
918
+ if (owner.kind !== this.actor.kind || owner.id !== this.actor.id) {
919
+ throw new SynomemError('MUTATION_FORBIDDEN', 'A todo is private to the actor who created it.');
920
+ }
921
+ }
922
+ async getTodo(id) {
923
+ this.checkAbort();
924
+ return await this.getTodoRecord(id);
925
+ }
926
+ async updateTodo(input) {
927
+ this.checkAbort();
928
+ const parsed = this.validate(() => updateTodoSchema.parse(input));
929
+ const record = await this.getTodoRecord(parsed.todoId);
930
+ if (record.current.version !== parsed.expectedVersion) {
931
+ throw new SynomemError('REVISION_CONFLICT', `Todo ${parsed.todoId} is at version ${record.current.version}.`);
932
+ }
933
+ const due = parsed.due === null ? undefined : (parsed.due ?? record.current.due);
934
+ await this.repository.transaction(async () => {
935
+ const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.updated');
936
+ if (prior)
937
+ return;
938
+ const event = {
939
+ ...this.eventBase(record.event.id, await this.repository.nextAggregateVersion(record.event.id)),
940
+ type: 'todo.updated',
941
+ todoId: record.event.id,
942
+ title: parsed.title ?? record.current.title,
943
+ ...((parsed.details ?? record.current.details)
944
+ ? { details: parsed.details ?? record.current.details }
945
+ : {}),
946
+ priority: parsed.priority ?? record.current.priority,
947
+ ...(due ? { due } : {}),
948
+ tags: [...new Set(parsed.tags ?? record.current.tags)].sort(),
949
+ ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
950
+ };
951
+ await this.repository.insertEvent(event);
952
+ });
953
+ return await this.getTodoRecord(parsed.todoId);
954
+ }
955
+ async todoTransition(input, type) {
956
+ const record = await this.getTodoRecord(input.todoId);
957
+ if (type === 'todo.completed' && record.status !== 'open') {
958
+ if (record.status === 'completed')
959
+ return record;
960
+ throw new SynomemError('INVALID_INPUT', 'Only an open todo may be completed.');
961
+ }
962
+ if (type === 'todo.reopened' && record.status === 'open')
963
+ return record;
964
+ if (type === 'todo.canceled' && record.status !== 'open') {
965
+ if (record.status === 'canceled')
966
+ return record;
967
+ throw new SynomemError('INVALID_INPUT', 'Only an open todo may be canceled.');
968
+ }
969
+ if (type === 'todo.archived' && record.status === 'archived')
970
+ return record;
971
+ await this.repository.transaction(async () => {
972
+ const prior = await this.priorMutation(input.idempotencyKey, type);
973
+ if (prior)
974
+ return;
975
+ const base = {
976
+ ...this.eventBase(record.event.id, await this.repository.nextAggregateVersion(record.event.id)),
977
+ todoId: record.event.id,
978
+ ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
979
+ };
980
+ const event = type === 'todo.completed'
981
+ ? { ...base, type, ...(input.note ? { note: input.note.trim() } : {}) }
982
+ : type === 'todo.canceled'
983
+ ? { ...base, type, ...(input.reason ? { reason: input.reason.trim() } : {}) }
984
+ : { ...base, type };
985
+ await this.repository.insertEvent(event);
986
+ });
987
+ return await this.getTodoRecord(input.todoId);
740
988
  }
741
989
  async listItems(input) {
742
990
  this.checkAbort();
@@ -765,7 +1013,7 @@ export class SynomemCore {
765
1013
  return this.getMemo(id);
766
1014
  if (summary.kind === 'note')
767
1015
  return this.getNote(id);
768
- return this.getTodo(id);
1016
+ return this.getTask(id);
769
1017
  }
770
1018
  async stats(input = {}) {
771
1019
  this.checkAbort();
@@ -818,6 +1066,14 @@ export class SynomemCore {
818
1066
  return stats;
819
1067
  }
820
1068
  }
1069
+ /**
1070
+ * The schema version this package writes and expects. Named rather than
1071
+ * repeated as a literal because it is asserted in three places, and a doctor
1072
+ * check that silently lags the migration runner reports a healthy database as
1073
+ * broken.
1074
+ */
1075
+ const CURRENT_SCHEMA_VERSION = 5;
1076
+ const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5];
821
1077
  export class SynomemClient extends SynomemCore {
822
1078
  home;
823
1079
  storage;
@@ -915,8 +1171,9 @@ export class SynomemClient extends SynomemCore {
915
1171
  message: `${itemHealth.indexed} of ${itemHealth.created} item aggregates are present in the shared current-state index.`,
916
1172
  });
917
1173
  const migrationState = this.storage.migrationState();
918
- const migrationsValid = migrationState.schemaVersion === 3 &&
919
- JSON.stringify(migrationState.appliedVersions) === JSON.stringify([1, 2, 3]);
1174
+ const migrationsValid = migrationState.schemaVersion === CURRENT_SCHEMA_VERSION &&
1175
+ JSON.stringify(migrationState.appliedVersions) ===
1176
+ JSON.stringify(EXPECTED_APPLIED_MIGRATIONS);
920
1177
  diagnostics.push({
921
1178
  level: migrationsValid ? 'ok' : 'error',
922
1179
  code: migrationsValid ? 'MIGRATIONS_VALID' : 'MIGRATIONS_INCONSISTENT',
@@ -987,7 +1244,7 @@ export class SynomemClient extends SynomemCore {
987
1244
  const records = recordsFromEvents(events);
988
1245
  const memos = memoRecordsFromEvents(events);
989
1246
  const notes = noteRecordsFromEvents(events);
990
- const todos = todoRecordsFromEvents(events);
1247
+ const tasks = taskRecordsFromEvents(events);
991
1248
  const warning = scan.invalid.length
992
1249
  ? `> Warning: ${scan.invalid.length} unsupported or malformed event(s) omitted from this Markdown view: ${scan.invalid.map((item) => item.id).join(', ')}\n\n`
993
1250
  : '';
@@ -995,7 +1252,7 @@ export class SynomemClient extends SynomemCore {
995
1252
  ...records.map((record) => `## Kudos: ${escapeMarkdown(record.event.title)}\n\n${escapeMarkdown(record.event.reason)}\n\nStatus: ${record.revocationStatus === 'revoked' ? 'Revoked' : record.status}\n\nID: \`${record.event.id}\``),
996
1253
  ...memos.map((record) => `## Memo: ${escapeMarkdown(record.event.subject)}\n\n${escapeMarkdown(record.event.body)}\n\nStatus: ${record.status}\n\nID: \`${record.event.id}\``),
997
1254
  ...notes.map((record) => `## Note: ${escapeMarkdown(record.current.title)}\n\n${escapeMarkdown(record.current.body)}\n\nStatus: ${record.status}; version ${record.current.version}\n\nID: \`${record.event.id}\``),
998
- ...todos.map((record) => `## 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}\``),
1255
+ ...tasks.map((record) => `## 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}\``),
999
1256
  ];
1000
1257
  return `${warning}${sections.join('\n\n')}\n`;
1001
1258
  }