@orboto/mcp 0.195.0 → 0.197.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 (40) hide show
  1. package/dist/event-bridge.js +5 -0
  2. package/dist/event-bridge.test.js +34 -0
  3. package/dist/http-transport.js +6 -3
  4. package/dist/manifest-size.test.js +4 -2
  5. package/dist/orboto-client.js +1 -1
  6. package/dist/server.js +10 -0
  7. package/dist/tool-docs.js +1 -1
  8. package/dist/tools/agent-coordination.js +17 -23
  9. package/dist/tools/agent-coordination.test.js +40 -6
  10. package/dist/tools/agent-message-work.js +22 -0
  11. package/dist/tools/agent-message-work.test.js +33 -0
  12. package/dist/tools/agent-messages.js +18 -8
  13. package/dist/tools/agent-presence-schema.js +35 -0
  14. package/dist/tools/claim.js +22 -19
  15. package/dist/tools/claim.test.js +7 -5
  16. package/dist/tools/get-ticket.js +3 -0
  17. package/dist/tools/git-rotate-token.js +61 -0
  18. package/dist/tools/git-rotate-token.test.js +87 -0
  19. package/dist/tools/list-git-app-installations.js +1 -10
  20. package/dist/tools/list-tickets.js +4 -0
  21. package/dist/tools/project-readiness-schema.js +16 -0
  22. package/dist/tools/project-readiness.js +16 -0
  23. package/dist/tools/project-readiness.test.js +19 -0
  24. package/dist/tools/raci.js +4 -2
  25. package/dist/tools/sentry.js +85 -0
  26. package/dist/tools/sentry.test.js +107 -0
  27. package/dist/tools/shared.js +9 -0
  28. package/dist/tools/spec-check.js +15 -0
  29. package/dist/tools/spec-check.test.js +23 -0
  30. package/dist/tools/spec-readiness.test.js +85 -0
  31. package/dist/tools/spec-schemas.js +34 -0
  32. package/dist/tools/ticket-writes.js +27 -22
  33. package/dist/tools/ticket-writes.test.js +3 -4
  34. package/dist/tools/update-project.js +19 -12
  35. package/dist/tools/update-project.test.js +13 -4
  36. package/dist/tools/work-next.js +1 -1
  37. package/dist/tools/work-sessions-next.test.js +23 -0
  38. package/dist/tools/work-sessions.js +30 -9
  39. package/dist/tools/work-sessions.test.js +14 -0
  40. package/package.json +1 -1
@@ -0,0 +1,85 @@
1
+ import { specReleaseInfo } from './shared.js';
2
+ import { makeWorkSessionsHandler } from './work-sessions.js';
3
+ import { readFileSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
+ import { z } from 'zod';
7
+ import { OrbotoApiError, OrbotoClient } from '../orboto-client.js';
8
+ import { makeWorkStartHandler, makeWorkSessionStartHandler, workStartToolConfig } from './work-sessions.js';
9
+ import { makeClaimHandler } from './claim.js';
10
+ import { specGateFailure, TicketSpecStateSchema } from './spec-schemas.js';
11
+ import { updateProjectToolConfig } from './update-project.js';
12
+ import { makeAssignHandler, updateTicketToolConfig } from './ticket-writes.js';
13
+ afterEach(() => { vi.restoreAllMocks(); });
14
+ describe('spec readiness tool contracts', () => {
15
+ it('keeps the isolated MCP state enum in sync with the API contract', () => {
16
+ const canonical = readFileSync(path.resolve(process.cwd(), '../../packages/shared-schema/src/spec-readiness.ts'), 'utf8');
17
+ const states = canonical.match(/TicketSpecStateSchema = z\.enum\(\[([^\]]+)\]/)?.[1].match(/'([^']+)'/g)?.map((value) => value.slice(1, -1));
18
+ expect(states).toEqual(TicketSpecStateSchema.options);
19
+ });
20
+ it.each([makeWorkStartHandler, makeWorkSessionStartHandler])('forwards a reasoned override and explains the server gate', async (factory) => {
21
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.test', apiKey: 'test' });
22
+ vi.spyOn(client, 'get').mockResolvedValueOnce({ id: 'project', key: 'ORB' }).mockResolvedValueOnce({ id: 'ticket', projectId: 'project' });
23
+ const post = vi.spyOn(client, 'post').mockRejectedValue(new OrbotoApiError(409, JSON.stringify({ errorKey: 'errors.tickets.spec_not_ready', errorParams: { state: 'needs_spec' } }), 'test'));
24
+ const result = await factory(client)({ ticketKey: 'ORB-2098', override: true, reason: 'Approved recovery' });
25
+ expect(post.mock.calls[0][1]).toMatchObject({ ticketId: 'ticket', override: true, reason: 'Approved recovery' });
26
+ expect(result).toMatchObject({ isError: true, structuredContent: { specGate: true, errorKey: 'errors.tickets.spec_not_ready', errorParams: { state: 'needs_spec' } } });
27
+ expect(JSON.stringify(result.content)).toContain('release the specification');
28
+ });
29
+ it('stops a repeated sole claim at the gate before removing someone else', async () => {
30
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.test', apiKey: 'test' });
31
+ vi.spyOn(client, 'get').mockResolvedValueOnce({ id: 'me' }).mockResolvedValueOnce({ id: 'project', key: 'ORB' }).mockResolvedValueOnce({ id: 'ticket', projectId: 'project', statusCategory: 'in_progress', assignees: [{ userId: 'me' }, { userId: 'other' }] });
32
+ vi.spyOn(client, 'post').mockRejectedValue(new OrbotoApiError(409, '{"errorKey":"errors.tickets.spec_not_ready"}', 'test'));
33
+ const remove = vi.spyOn(client, 'delete');
34
+ const patch = vi.spyOn(client, 'patch');
35
+ expect(await makeClaimHandler(client)({ ticketKey: 'ORB-2098', sole: true })).toMatchObject({ isError: true, structuredContent: { specGate: true } });
36
+ expect(remove).not.toHaveBeenCalled();
37
+ expect(patch).not.toHaveBeenCalled();
38
+ });
39
+ it.each([
40
+ ['errors.tickets.already_assigned', true],
41
+ ['errors.tickets.spec_not_ready', false],
42
+ ['errors.work_sessions.lease_held', false],
43
+ ])('only tolerates an already-assigned conflict: %s', async (errorKey, tolerated) => {
44
+ for (const kind of ['claim', 'assign']) {
45
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.test', apiKey: 'test' });
46
+ const get = vi.spyOn(client, 'get');
47
+ if (kind === 'claim')
48
+ get.mockResolvedValueOnce({ id: 'me' });
49
+ get.mockResolvedValueOnce({ id: 'project', key: 'ORB' }).mockResolvedValueOnce({ id: 'ticket', projectId: 'project', statusCategory: 'in_progress', assignees: [{ userId: 'me' }] });
50
+ if (kind === 'assign')
51
+ get.mockResolvedValueOnce([{ userId: 'me', user: { id: 'me', email: 'me@spec.test' } }]);
52
+ const error = new OrbotoApiError(409, JSON.stringify({ errorKey }), 'test');
53
+ vi.spyOn(client, 'post').mockRejectedValue(error);
54
+ const result = kind === 'claim' ? makeClaimHandler(client)({ ticketKey: 'ORB-2098', noTimer: true }) : makeAssignHandler(client)({ ticketKey: 'ORB-2098', assigneeEmail: 'me@spec.test' });
55
+ if (tolerated)
56
+ expect(await result).not.toHaveProperty('isError', true);
57
+ else if (kind === 'claim' && errorKey === 'errors.tickets.spec_not_ready')
58
+ expect(await result).toHaveProperty('isError', true);
59
+ else
60
+ await expect(result).rejects.toBe(error);
61
+ }
62
+ });
63
+ it('exposes the settings, state and spec role and preserves unrelated conflict parsing', () => {
64
+ expect(z.object(workStartToolConfig.inputSchema).parse({ ticketKey: 'ORB-2098', role: 'spec' }).role).toBe('spec');
65
+ expect(z.object(updateTicketToolConfig.inputSchema).parse({ ticketKey: 'ORB-2098', patch: { specState: 'ready' } }).patch.specState).toBe('ready');
66
+ expect(z.object(updateProjectToolConfig.inputSchema).parse({ projectKey: 'ORB', patch: { spec: { enabled: true, requiredTypes: ['story'], releaseBy: 'author' } } }).patch.spec?.releaseBy).toBe('author');
67
+ expect(z.object(updateProjectToolConfig.inputSchema).parse({ projectKey: 'ORB', patch: { spec: { enabled: true } } }).patch.spec).toEqual({ enabled: true });
68
+ expect(specGateFailure('not json')).toBeUndefined();
69
+ expect(specGateFailure('{"errorKey":"errors.work_sessions.lease_held"}')).toBeUndefined();
70
+ expect(JSON.stringify(specGateFailure('{"errorKey":"errors.tickets.epic_not_claimable"}'))).toContain('child stories');
71
+ });
72
+ });
73
+ it('preserves release provenance without exposing the internal fingerprint', () => {
74
+ const ticket = { specReleasedBy: 'author', specReleasedByFullName: 'Alice', specReleasedAt: '2026-09-14T10:00:00Z', specReleasePolicy: 'author', specReleaseRole: 'author', specReleaseFingerprint: 'internal-proof' };
75
+ expect(specReleaseInfo(ticket)).toEqual({ specReleasedBy: 'author', specReleasedByFullName: 'Alice', specReleasedAt: '2026-09-14T10:00:00Z', specReleasePolicy: 'author', specReleaseRole: 'author' });
76
+ });
77
+ it('describes waiting tasks as parked sessions and preserves the question reference', async () => {
78
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.test', apiKey: 'test' });
79
+ const row = { id: 'session', ticketId: 'ticket', ticketKey: 'ORB-2100', role: 'spec', status: 'finished', waitingForAnswers: true, taskId: 'task', waitingCommentId: 'question', leaseUntil: 'past' };
80
+ vi.spyOn(client, 'get').mockResolvedValue([row]);
81
+ const result = await makeWorkSessionsHandler(client)({});
82
+ expect(result.structuredContent).toEqual({ sessions: [row] });
83
+ expect(JSON.stringify(result.content)).toContain('waiting for answers; session finished; task task; question question');
84
+ expect(JSON.stringify(result.content)).not.toContain('lease until');
85
+ });
@@ -0,0 +1,34 @@
1
+ import { z } from 'zod';
2
+ export const TicketSpecStateSchema = z.enum(['none', 'needs_spec', 'in_spec', 'ready']);
3
+ export const ProjectSpecSettingsSchema = z.object({
4
+ enabled: z.boolean().optional(),
5
+ requiredTypes: z.array(z.enum(['epic', 'story', 'task', 'bug'])).optional(),
6
+ releaseBy: z.enum(['agent', 'author']).optional(),
7
+ }).strict();
8
+ export function specGateFailure(body) {
9
+ let failure;
10
+ try {
11
+ failure = JSON.parse(body);
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ if (!failure || !['errors.tickets.spec_not_ready', 'errors.tickets.epic_not_claimable'].includes(failure.errorKey ?? ''))
17
+ return undefined;
18
+ return {
19
+ isError: true,
20
+ content: [{ type: 'text', text: failure.errorKey === 'errors.tickets.spec_not_ready'
21
+ ? `Specification is ${failure.errorParams?.state ?? 'not ready'}. Complete and release the specification, or explicitly set override=true with a reason.`
22
+ : 'Epics are task groups. Use the spec role to prepare child stories.' }],
23
+ structuredContent: { ...failure, specGate: true },
24
+ };
25
+ }
26
+ export function isAlreadyAssigned(body) {
27
+ try {
28
+ const error = JSON.parse(body);
29
+ return error?.errorKey === 'errors.tickets.already_assigned' || (!error?.errorKey && error?.error?.trim().toLowerCase() === 'already assigned');
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
@@ -1,3 +1,5 @@
1
+ import { specReleaseInfo } from './shared.js';
2
+ import { TicketSpecStateSchema, isAlreadyAssigned } from './spec-schemas.js';
1
3
  /**
2
4
  * ORB-244 Phase C Group 1 - ticket mutation tools.
3
5
  *
@@ -42,6 +44,8 @@ function ticketStructured(t) {
42
44
  statusCategory: t.statusCategory ?? null,
43
45
  type: t.type,
44
46
  priority: t.priority,
47
+ specState: t.specState ?? 'none',
48
+ ...specReleaseInfo(t),
45
49
  deliveryMode: t.deliveryMode ?? 'implementation',
46
50
  dueDate: t.dueDate,
47
51
  isPrivate: t.isPrivate,
@@ -55,14 +59,17 @@ export const createTicketToolConfig = {
55
59
  projectKey: z.string().min(1).describe('Project key (e.g. "ACME").'),
56
60
  title: z.string().min(1).max(255),
57
61
  description: z.string().optional(),
58
- type: z.enum(['task', 'bug', 'story', 'epic']).optional().describe('Default: task.'),
59
- priority: z.enum(['blocker', 'high', 'normal', 'low', 'trivial']).optional().describe('Default: normal.'),
60
- deliveryMode: z.enum(['implementation', 'docs', 'review', 'admin', 'epic']).optional().describe('Default: epic when type=epic, else implementation.'),
62
+ type: z.enum(['task', 'bug', 'story', 'epic']).optional(),
63
+ priority: z.enum(['blocker', 'high', 'normal', 'low', 'trivial']).optional(),
64
+ override: z.boolean().optional(),
65
+ reason: z.string().trim().min(1).max(2000).optional(),
66
+ specState: TicketSpecStateSchema.optional(),
67
+ deliveryMode: z.enum(['implementation', 'docs', 'review', 'admin', 'epic']).optional(),
61
68
  milestone: z.string().optional().describe('Key ("ORB-M3"), name, or UUID.'),
62
69
  assigneeEmails: z.array(z.string().email()).optional().describe('Project-member emails.'),
63
- labels: z.array(z.string()).optional().describe('Existing label names.'),
70
+ labels: z.array(z.string()).optional(),
64
71
  parentTicketKey: z.string().optional().describe('Parent key - makes this a sub-ticket.'),
65
- dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('YYYY-MM-DD.'),
72
+ dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
66
73
  isPrivate: z.boolean().optional(),
67
74
  allowLanguageMismatch: z.boolean().optional().describe('Override the language block.'),
68
75
  allowDuplicate: z.boolean().optional().describe('Override the duplicate block.'),
@@ -79,6 +86,9 @@ export function makeCreateTicketHandler(client) {
79
86
  type: input.type ?? 'task',
80
87
  priority: input.priority ?? 'normal',
81
88
  isPrivate: input.isPrivate ?? false,
89
+ ...(input.override !== undefined ? { override: input.override } : {}),
90
+ ...(input.reason ? { reason: input.reason } : {}),
91
+ ...(input.specState ? { specState: input.specState } : {}),
82
92
  ...(input.deliveryMode ? { deliveryMode: input.deliveryMode } : {}),
83
93
  };
84
94
  if (input.allowDuplicate && input.duplicateJustification)
@@ -236,22 +246,23 @@ function formatSimilarity(w) {
236
246
  }
237
247
  export const updateTicketToolConfig = {
238
248
  title: 'Update a ticket',
239
- description: 'Patch one or more fields on a ticket. Patchable: title, description, customerSummary, type, priority, deliveryMode, dueDate, startDate, isPrivate, estimatedTimeMinutes. `customerSummary` is the customer-facing text shown in the customer project report instead of the internal description; null or empty falls back to an AI distillation or the title. `deliveryMode` is the role-aware commit policy (ORB-1608) - see `orboto_create_ticket` for the mode semantics. Use `orboto_move_ticket` for status, `orboto_set_milestone` for milestone, and `orboto_assign` / `orboto_unassign` for members.',
249
+ description: 'Patch one or more fields on a ticket. Patchable: title, description, customerSummary, type, priority, specState, deliveryMode, dueDate, startDate, isPrivate, estimatedTimeMinutes. `customerSummary` is the customer-facing text shown in the customer project report instead of the internal description; null or empty falls back to an AI distillation or the title. `deliveryMode` is the role-aware commit policy (ORB-1608) - see `orboto_create_ticket` for the mode semantics. Use `orboto_move_ticket` for status, `orboto_set_milestone` for milestone, and `orboto_assign` / `orboto_unassign` for members.',
240
250
  inputSchema: z.object({
241
251
  ticketKey: z.string().min(3),
242
252
  patch: z.object({
243
253
  title: z.string().min(1).max(255).optional(),
244
254
  description: z.string().optional(),
245
- customerSummary: z.string().max(2000).nullable().optional().describe('Customer-facing report text; null falls back to AI/title.'),
255
+ customerSummary: z.string().max(2000).nullable().optional().describe('Report text; null uses AI/title.'),
246
256
  type: z.enum(['task', 'bug', 'story', 'epic']).optional(),
247
257
  priority: z.enum(['blocker', 'high', 'normal', 'low', 'trivial']).optional(),
248
- deliveryMode: z.enum(['implementation', 'docs', 'review', 'admin', 'epic']).optional().describe('Commit policy; see orboto_create_ticket.'),
258
+ specState: TicketSpecStateSchema.optional(),
259
+ deliveryMode: z.enum(['implementation', 'docs', 'review', 'admin', 'epic']).optional(),
249
260
  dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
250
261
  startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
251
262
  isPrivate: z.boolean().optional(),
252
263
  estimatedTimeMinutes: z.number().int().nonnegative().optional(),
253
264
  }).refine((p) => Object.keys(p).length > 0, { message: 'patch must include at least one field' }),
254
- allowLanguageMismatch: z.boolean().optional().describe('Override the language block; only after a call was blocked.'),
265
+ allowLanguageMismatch: z.boolean().optional().describe('Retry a language block.'),
255
266
  }).shape,
256
267
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
257
268
  };
@@ -417,25 +428,19 @@ export const assignToolConfig = {
417
428
  inputSchema: z.object({
418
429
  ticketKey: z.string().min(3),
419
430
  assigneeEmail: z.string().email(),
431
+ override: z.boolean().optional(),
432
+ reason: z.string().trim().min(1).max(2000).optional(),
420
433
  }).shape,
421
434
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
422
435
  };
423
436
  export function makeAssignHandler(client) {
424
- return async ({ ticketKey, assigneeEmail }) => {
437
+ return async ({ ticketKey, assigneeEmail, override, reason }) => {
425
438
  const ticket = await resolveTicketByKey(client, ticketKey);
426
439
  const userId = await resolveAssigneeId(client, ticket.projectId, assigneeEmail);
427
- try {
428
- await client.post(`/projects/${ticket.projectId}/tickets/${ticket.id}/assignees/${userId}`, {});
429
- }
430
- catch (err) {
431
- if (err instanceof OrbotoApiError && err.status === 409) {
432
- return {
433
- content: [{ type: 'text', text: `[${ticket.ticketKey}] already assigned to ${assigneeEmail}.` }],
434
- structuredContent: { ticketKey: ticket.ticketKey, alreadyAssigned: true },
435
- };
436
- }
437
- throw err;
438
- }
440
+ await client.post(`/projects/${ticket.projectId}/tickets/${ticket.id}/assignees/${userId}`, { override, reason }).catch((error) => {
441
+ if (!(error instanceof OrbotoApiError) || error.status !== 409 || !isAlreadyAssigned(error.body))
442
+ throw error;
443
+ });
439
444
  return {
440
445
  content: [{ type: 'text', text: `Assigned ${assigneeEmail} to [${ticket.ticketKey}].` }],
441
446
  structuredContent: { ticketKey: ticket.ticketKey, assignedEmail: assigneeEmail },
@@ -27,7 +27,7 @@ function stub(responses) {
27
27
  status: r.status ?? 200,
28
28
  statusText: 'OK',
29
29
  json: async () => ('json' in r ? r.json : {}),
30
- text: async () => '',
30
+ text: async () => r.json === undefined ? '' : JSON.stringify(r.json),
31
31
  };
32
32
  });
33
33
  return calls;
@@ -449,15 +449,14 @@ describe('orboto_assign / orboto_unassign', () => {
449
449
  expect(calls[3].method).toBe('POST');
450
450
  expect(calls[3].url).toContain('/assignees/u1');
451
451
  });
452
- it('assign treats 409 as idempotent already-assigned', async () => {
452
+ it('assign tolerates legacy already-assigned responses', async () => {
453
453
  stub([
454
454
  { json: PROJ },
455
455
  { json: TICKET },
456
456
  { json: MEMBERS },
457
457
  { ok: false, status: 409, json: { error: 'already assigned' } },
458
458
  ]);
459
- const res = await makeAssignHandler(client)({ ticketKey: 'ACME-1', assigneeEmail: 'ada@acme' });
460
- expect(res.content[0].text).toContain('already assigned');
459
+ expect(await makeAssignHandler(client)({ ticketKey: 'ACME-1', assigneeEmail: 'ada@acme' })).toMatchObject({ structuredContent: { assignedEmail: 'ada@acme' } });
461
460
  });
462
461
  it('unassign treats 404 as idempotent already-unassigned', async () => {
463
462
  stub([
@@ -1,3 +1,5 @@
1
+ import { ProjectReadinessSchema } from './project-readiness-schema.js';
2
+ import { ProjectSpecSettingsSchema } from './spec-schemas.js';
1
3
  /**
2
4
  * ORB-885 - `orboto_update_project`.
3
5
  * ORB-830 - `orboto_create_project` + `orboto_archive_project`.
@@ -13,18 +15,19 @@ export const updateProjectToolConfig = {
13
15
  title: 'Update a project\'s metadata',
14
16
  description: 'Patch a project (`name`, `description`, `key`, `status`, `branchTemplate`, `customerId`, `language`). At least one field must be set. Use `status: "archived"` or `"closed"` to take a project out of active rotation. Renaming the `key` also rewrites every ticket\'s `PROJ-N` reference - handle with care. `language` sets the project\'s expected ticket language (one of the 18 supported locales); pass null to inherit the workspace language.',
15
17
  inputSchema: z.object({
16
- projectKey: z.string().min(1).describe('Current project key (e.g. "ACME"). Case-insensitive.'),
18
+ projectKey: z.string().min(1).describe('Current project key.'),
17
19
  patch: z.object({
20
+ spec: ProjectSpecSettingsSchema.optional(),
18
21
  name: z.string().min(1).max(255).optional(),
19
- description: z.string().nullable().optional().describe('Pass null to clear the description.'),
22
+ description: z.string().nullable().optional().describe('Null clears.'),
20
23
  key: z.string().min(2).max(10).regex(PROJECT_KEY_RE).optional()
21
- .describe('New project key. A-Z0-9 only, 2-10 chars. Rewrites every ticket reference.'),
24
+ .describe('New key; rewrites ticket references.'),
22
25
  status: z.enum(['draft', 'active', 'archived', 'closed']).optional(),
23
26
  branchTemplate: z.string().max(100).nullable().optional(),
24
27
  customerId: z.string().regex(UUID_RE).nullable().optional()
25
- .describe('UUID of a customer record, or null to detach.'),
28
+ .describe('Customer UUID; null detaches.'),
26
29
  language: z.enum(LOCALE_CODES).nullable().optional()
27
- .describe('Project content language (ORB-994). Wins over the workspace language for this project. null = inherit workspace.'),
30
+ .describe('Content language; null inherits workspace.'),
28
31
  }).refine((p) => Object.keys(p).length > 0, { message: 'patch must include at least one field' }),
29
32
  }).shape,
30
33
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
@@ -46,22 +49,23 @@ export function makeUpdateProjectHandler(client) {
46
49
  description: updated.description,
47
50
  status: updated.status,
48
51
  language: updated.language ?? null,
52
+ spec: updated.spec,
49
53
  },
50
54
  };
51
55
  };
52
56
  }
53
57
  export const createProjectToolConfig = {
54
58
  title: 'Create a new project',
55
- description: 'Create a new project in the workspace. `name` is required; `key` is optional and the API auto-derives one from the name when omitted (uppercase initials). Returns the new project\'s id + key + status (`active` by default). Caller must have `admin:project:create` or be a super-admin. Note: a new project is auto-provisioned with a general doc space (slug `<key>-general`, system-generated, for the AI primer + notes) - don\'t create a separate doc space for its docs, reuse that one (orboto_list_doc_spaces).',
59
+ description: 'Create a project with an optional key (auto-derived if omitted). Requires project:create or super-admin. Returns identity, status and the measured readiness checklist with fix actions. Reuse its auto-created doc space via orboto_list_doc_spaces.',
56
60
  inputSchema: z.object({
57
- name: z.string().min(1).max(255).describe('Display name for the project.'),
61
+ name: z.string().min(1).max(255).describe('Project name.'),
58
62
  key: z.string().min(2).max(10).regex(PROJECT_KEY_RE).optional()
59
- .describe('Optional explicit key (e.g. "ACME"). A-Z0-9 only, 2-10 chars. Auto-derived from name when omitted.'),
60
- description: z.string().nullable().optional().describe('Optional free-text description.'),
63
+ .describe('Key; auto-derived if omitted.'),
64
+ description: z.string().nullable().optional().describe('Description.'),
61
65
  customerId: z.string().regex(UUID_RE).nullable().optional()
62
- .describe('Optional UUID of a customer record to attach the project to.'),
66
+ .describe('Customer UUID.'),
63
67
  language: z.enum(LOCALE_CODES).nullable().optional()
64
- .describe('Optional project content language (ORB-994), one of the 18 supported locales. Omit/null = inherit the workspace language.'),
68
+ .describe('Content language; null/omitted inherits workspace.'),
65
69
  }).shape,
66
70
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
67
71
  };
@@ -77,12 +81,15 @@ export function makeCreateProjectHandler(client) {
77
81
  if (language !== undefined)
78
82
  body.language = language;
79
83
  const created = await client.post('/projects', body);
84
+ const parsed = ProjectReadinessSchema.safeParse(created.readiness);
85
+ const readiness = parsed.success ? parsed.data : undefined;
80
86
  return {
81
87
  content: [{
82
88
  type: 'text',
83
- text: `Created project ${created.key} - ${created.name} (status: ${created.status}).`,
89
+ text: `Created project ${created.key} - ${created.name} (status: ${created.status}).${readiness ? ` Setup: ${readiness.ready ? 'ready' : 'incomplete; inspect readiness items and fix actions'}.` : ''}`,
84
90
  }],
85
91
  structuredContent: {
92
+ readiness,
86
93
  id: created.id,
87
94
  key: created.key,
88
95
  name: created.name,
@@ -86,10 +86,19 @@ describe('orboto_update_project', () => {
86
86
  })).rejects.toThrow(/Project "GHOST" not found/);
87
87
  });
88
88
  });
89
+ const readiness = { projectId: '11111111-2222-4333-8444-555555555555', projectKey: 'ACME', language: 'en', ready: false, ticketScope: 'visible', items: [] };
89
90
  describe('orboto_create_project', () => {
91
+ it.each([undefined, null, {}])('retains successful creation when readiness is absent or unsupported: %s', async (unsupported) => {
92
+ const calls = stub([{ json: { readiness: unsupported, id: 'older', key: 'OLD', name: 'Older API', status: 'active' } }]);
93
+ const result = await makeCreateProjectHandler(client)({ name: 'Older API' });
94
+ expect(result.structuredContent).toMatchObject({ id: 'older', key: 'OLD' });
95
+ expect(result.structuredContent?.readiness).toBeUndefined();
96
+ expect(result.content[0]).toMatchObject({ type: 'text', text: expect.stringContaining('Created project OLD') });
97
+ expect(calls).toHaveLength(1);
98
+ });
90
99
  it('POSTs name + key when both supplied', async () => {
91
100
  const calls = stub([
92
- { json: { id: 'new1', key: 'ACME', name: 'Acme', description: null, status: 'active' } },
101
+ { json: { readiness, id: 'new1', key: 'ACME', name: 'Acme', description: null, status: 'active' } },
93
102
  ]);
94
103
  const res = await makeCreateProjectHandler(client)({ name: 'Acme', key: 'ACME' });
95
104
  expect(calls[0]).toMatchObject({
@@ -97,18 +106,18 @@ describe('orboto_create_project', () => {
97
106
  url: 'https://orboto.example.com/projects',
98
107
  body: { name: 'Acme', key: 'ACME' },
99
108
  });
100
- expect(res.structuredContent).toMatchObject({ key: 'ACME', name: 'Acme', status: 'active' });
109
+ expect(res.structuredContent).toMatchObject({ key: 'ACME', name: 'Acme', status: 'active', readiness });
101
110
  });
102
111
  it('omits optional fields when not supplied so the API auto-derives the key', async () => {
103
112
  const calls = stub([
104
- { json: { id: 'new2', key: 'NN', name: 'No Name', description: null, status: 'active' } },
113
+ { json: { readiness, id: 'new2', key: 'NN', name: 'No Name', description: null, status: 'active' } },
105
114
  ]);
106
115
  await makeCreateProjectHandler(client)({ name: 'No Name' });
107
116
  expect(calls[0].body).toEqual({ name: 'No Name' });
108
117
  });
109
118
  it('passes description + customerId through verbatim', async () => {
110
119
  const calls = stub([
111
- { json: { id: 'new3', key: 'CUS', name: 'Custom', description: 'hello', status: 'active' } },
120
+ { json: { readiness, id: 'new3', key: 'CUS', name: 'Custom', description: 'hello', status: 'active' } },
112
121
  ]);
113
122
  await makeCreateProjectHandler(client)({
114
123
  name: 'Custom', description: 'hello', customerId: '11111111-2222-3333-4444-555555555555',
@@ -28,7 +28,7 @@ export const workNextToolConfig = {
28
28
  projectKey: z.string().min(1).describe('Project key (e.g. "ACME") or UUID.'),
29
29
  agentTag: z.string().min(1).max(64).optional()
30
30
  .describe('ORB-1772 - preferred-not-exclusive routing tag: tickets labeled `agent:<tag>` rank first for a matching caller, foreign `agent:*` tags rank last but stay eligible. Lowercased server-side. Set it to this worker\'s routing tag (often the model or fleet lane name).'),
31
- role: z.enum(['implementation', 'review', 'preflight', 'integration']).optional()
31
+ role: z.enum(['implementation', 'review', 'preflight', 'integration', 'spec']).optional()
32
32
  .describe('Default `implementation`. The dispatcher only reserves a ticket whose (ticket, role) lease is free for THIS role.'),
33
33
  leaseSeconds: z.number().int().min(60).max(86_400).optional()
34
34
  .describe('How long the lease should hold without renewal. Default 900 (15 min).'),
@@ -88,6 +88,29 @@ describe('orboto_work_next', () => {
88
88
  stub([{ json: { ...peekResponse, ...patch } }]);
89
89
  await expect(makeWorkNextHandler(client)({ projectKey: 'ACME', peek: true })).rejects.toMatchObject({ reason: 'invalid_response' });
90
90
  });
91
+ it('preserves the spec bundle, queued ownership and landed work across pause and reservation', async () => {
92
+ const landedIdle = [{ ticketId: 'old', ticketKey: 'ACME-7', title: 'Already implemented', idleWorkingDays: 3, lastActivityAt: '2026-09-10', commitCount: 2 }];
93
+ const reserved = {
94
+ ...RESERVED, session: { ...SESSION, role: 'spec', activeTimerId: null },
95
+ ticket: { ...RESERVED.ticket, type: 'story', specState: 'in_spec' },
96
+ queued: [{ claim: { kind: 'path', value: 'src/**', mode: 'write' }, position: 2 }],
97
+ checklists: [{ title: 'Build order', triggersDone: true, progress: { done: 1, total: 2 }, items: [{ content: 'Reviewed requirement', effectiveCompleted: true, linkedTicketKey: 'ACME-8', linkedTicketStatusCategory: 'done' }, { content: 'Open question', effectiveCompleted: false }] }],
98
+ dependencies: { blockedBy: [{ ticketKey: 'ACME-9', title: 'Design', statusName: 'Done' }], blocks: [{ resolved: false }] },
99
+ gitHealth: [{ name: 'Repository', provider: 'forgejo', healthy: false, reason: 'missing_token' }],
100
+ siblingSessions: [{ ...SESSION, role: 'review', userEmail: 'reviewer@example.test' }],
101
+ };
102
+ const calls = stub([{ json: { reserved: null, reason: 'autonomy_paused', retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 0, landedIdle } }, { json: { reserved, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1, landedIdle } }]);
103
+ const handler = makeWorkNextHandler(client);
104
+ const paused = await handler({ projectKey: 'ACME', role: 'spec' });
105
+ expect(JSON.stringify(paused.content)).toContain('PAUSED');
106
+ expect(paused.structuredContent).toMatchObject({ landedIdle, retryAfterSeconds: null });
107
+ const result = await handler({ projectKey: 'ACME', role: 'spec' });
108
+ expect(calls[1].body?.role).toBe('spec');
109
+ expect(result.structuredContent).toMatchObject({ reserved: { ticket: { specState: 'in_spec' }, queued: reserved.queued }, landedIdle });
110
+ const text = JSON.stringify(result.content);
111
+ for (const content of ['Build order', 'Open question', 'Repository', 'reviewer@example.test', 'Already implemented', 'position 2', 'No timer started'])
112
+ expect(text).toContain(content);
113
+ });
91
114
  it('reserves the winning candidate and renders the full bundle, sending projectKey straight through', async () => {
92
115
  const calls = stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 3, landedIdle: [] } }]);
93
116
  const res = await makeWorkNextHandler(client)({ projectKey: 'ACME' });
@@ -1,3 +1,4 @@
1
+ import { specGateFailure } from './spec-schemas.js';
1
2
  /**
2
3
  * ORB-1609 - work-session tools.
3
4
  */
@@ -28,17 +29,21 @@ function describeClaimConflicts(conflicts) {
28
29
  function describe(s) {
29
30
  const key = s.ticketKey ?? s.ticketId.slice(0, 8);
30
31
  const who = s.userFullName ?? s.userEmail ?? 'unknown';
32
+ if (s.waitingForAnswers)
33
+ return ` - ${key} [${s.role}] ${who} - waiting for answers; session ${s.status}; task ${s.taskId}; question ${s.waitingCommentId}`;
31
34
  return ` - ${key} [${s.role}] ${who} - lease until ${s.leaseUntil}${s.commitSha ? ` (commit ${s.commitSha.slice(0, 8)})` : ''}`;
32
35
  }
33
36
  export const workStartToolConfig = {
34
37
  title: 'Start a work session and load the full context bundle',
35
- description: 'ORB-1611 - the one-call ticket pickup. Acquires the (ticket, role) work lease with the exact same guarantees as orboto_work_session_start (exactly ONE active session per ticket+role workspace-wide, a conflict names the holder, resourceClaims apply atomically with the lease), AND in the SAME response returns the rules-hash ack (same semantics as orboto_session_start), the project primer, the ticket enriched with its description/status/priority, its checklists, its dependencies, that project\'s git connection health, and any other live sessions already on the ticket. This replaces the 8-15 separate calls (orboto_session_start, orboto_get_project_primer, orboto_get_ticket, orboto_get_checklists, orboto_list_ticket_dependencies, ...) a normal ticket pickup used to cost. Prefer this over orboto_work_session_start for picking up a ticket; use the plain tool only when you deliberately do not want the bundle (e.g. a mid-task lease renewal where you already have fresh context). A conflict never leaves a partial session behind - same rollback-on-conflict guarantee as orboto_work_session_start.',
38
+ description: 'Claim a ticket lease with its rules and work context. Includes the primer, enriched ticket, checklists, dependencies, git health and live sessions. Exactly one active lease per ticket and role across the workspace. Resource claims are atomic with the lease; conflicts name the holder and leave no partial session. Prefer this for pickup; use orboto_work_session_start for renewal when context is already loaded. Spec-tagged bots may use role=spec to prepare the build order. Worker bots need a ready specification, or override=true with a reason; enabled epics cannot be overridden.',
36
39
  inputSchema: z.object({
37
40
  ticketKey: z.string().min(3).describe('Ticket key like "ACME-42".'),
38
- role: z.enum(['implementation', 'review', 'preflight', 'integration']).optional()
39
- .describe('Default `implementation`. Use `review` for a review pass, `preflight` for a pre-work check, `integration` for merge/release work - those attach without reassigning the ticket.'),
41
+ role: z.enum(['implementation', 'review', 'preflight', 'integration', 'spec']).optional()
42
+ .describe('Default implementation; other roles attach without reassignment. Spec prepares build orders; bots need the spec tag.'),
40
43
  leaseSeconds: z.number().int().min(60).max(86_400).optional()
41
44
  .describe('How long the lease should hold without renewal. Default 900 (15 min).'),
45
+ override: z.boolean().optional().describe("Bypass the spec gate; reason required. Never epics."),
46
+ reason: z.string().trim().min(1).max(2000).optional(),
42
47
  takeover: z.boolean().optional()
43
48
  .describe('Displace the current holder of this (ticket, role) lease. Their session is cancelled and their tracked time booked.'),
44
49
  startTimer: z.boolean().optional()
@@ -69,6 +74,8 @@ export function makeWorkStartHandler(client) {
69
74
  ticketId,
70
75
  ...(args.role ? { role: args.role } : {}),
71
76
  ...(args.leaseSeconds ? { leaseSeconds: args.leaseSeconds } : {}),
77
+ ...(args.override !== undefined ? { override: args.override } : {}),
78
+ ...(args.reason ? { reason: args.reason } : {}),
72
79
  ...(args.takeover ? { takeover: true } : {}),
73
80
  ...(args.startTimer !== undefined ? { startTimer: args.startTimer } : {}),
74
81
  ...(args.resourceClaims && args.resourceClaims.length > 0 ? { resourceClaims: args.resourceClaims } : {}),
@@ -153,6 +160,9 @@ export function makeWorkStartHandler(client) {
153
160
  }
154
161
  catch (err) {
155
162
  if (err instanceof OrbotoApiError && err.status === 409) {
163
+ const specFailure = specGateFailure(err.body);
164
+ if (specFailure)
165
+ return specFailure;
156
166
  const claimConflicts = parseClaimConflicts(err);
157
167
  if (claimConflicts && claimConflicts.length > 0) {
158
168
  return {
@@ -192,13 +202,15 @@ export function makeWorkStartHandler(client) {
192
202
  }
193
203
  export const workSessionStartToolConfig = {
194
204
  title: 'Start (or renew) a work session on a ticket',
195
- description: 'Take the work lease on a ticket in a given role and start its timer. This is the coordination primitive: exactly ONE active session per (ticket, role) exists workspace-wide, so a second agent attempting the same role gets a conflict naming the current holder instead of silently colliding. Re-calling with the same agent instance renews your own lease and is a no-op otherwise - safe to call defensively. Roles other than `implementation` (review / preflight / integration) attach to the ticket WITHOUT reassigning it or moving its status, so a reviewing agent no longer has to fake a claim. The lease expires on its own (default 15 min, renewed automatically by your subsequent calls), so a crashed agent never wedges a ticket. Pass `takeover: true` only when you have decided to displace the current holder - their session is closed and their time booked, and the takeover is visible in history.',
205
+ description: 'Take the work lease on a ticket in a given role and start its timer. This is the coordination primitive: exactly ONE active session per (ticket, role) exists workspace-wide, so a second agent attempting the same role gets a conflict naming the current holder instead of silently colliding. Re-calling with the same agent instance renews your own lease and is a no-op otherwise - safe to call defensively. Roles other than `implementation` (review / preflight / integration / spec) attach to the ticket WITHOUT reassigning it or moving its status, so a reviewing agent no longer has to fake a claim. The lease expires on its own (default 15 min, renewed automatically by your subsequent calls), so a crashed agent never wedges a ticket. Pass `takeover: true` only when you have decided to displace the current holder - their session is closed and their time booked, and the takeover is visible in history.',
196
206
  inputSchema: z.object({
197
207
  ticketKey: z.string().min(3).describe('Ticket key like "ACME-42".'),
198
- role: z.enum(['implementation', 'review', 'preflight', 'integration']).optional()
199
- .describe('Default `implementation`. Use `review` for a review pass, `preflight` for a pre-work check, `integration` for merge/release work - those attach without reassigning the ticket.'),
208
+ role: z.enum(['implementation', 'review', 'preflight', 'integration', 'spec']).optional()
209
+ .describe('Default implementation; other roles attach without reassignment. Spec prepares build orders; bots need the spec tag.'),
200
210
  leaseSeconds: z.number().int().min(60).max(86_400).optional()
201
211
  .describe('How long the lease should hold without renewal. Default 900 (15 min).'),
212
+ override: z.boolean().optional().describe("Bypass the spec gate; reason required. Never epics."),
213
+ reason: z.string().trim().min(1).max(2000).optional(),
202
214
  takeover: z.boolean().optional()
203
215
  .describe('Displace the current holder of this (ticket, role) lease. Their session is cancelled and their tracked time booked.'),
204
216
  startTimer: z.boolean().optional()
@@ -206,9 +218,9 @@ export const workSessionStartToolConfig = {
206
218
  agentSessionToken: z.string().optional()
207
219
  .describe('Stable per-agent-instance token. Omit to use this MCP connection\'s own instance id.'),
208
220
  resourceClaims: z.array(z.object(ResourceClaimShape)).max(50).optional()
209
- .describe('ORB-1610 - resource claims to acquire alongside the lease. `kind: "path"` takes a glob relative to the repo root (`src/**`, `apps/api/src/routes/tickets.ts`); `kind: "named"` takes an opaque exclusive-resource id (`unity-editor:main`, `git-push:orboto#develop`), compared by exact string equality. `mode: "write"` conflicts with any OVERLAPPING active write claim workspace-wide (across tickets and accounts) - editor refresh clobbering uncommitted changes and concurrent pushes staging each other\'s files are exactly what this prevents. `mode: "read"` never conflicts with anything, including another read.'),
221
+ .describe('Atomic resource claims: path values are repo-relative globs, named values are exact ids. Overlapping writes conflict across the workspace; reads never conflict.'),
210
222
  onConflict: z.enum(['reject', 'queue']).optional()
211
- .describe('Only matters when `resourceClaims` is set. Default `reject`: a conflicting write claim fails the WHOLE call with the conflicting holder(s) named - if this call would have created a brand-new session, that session is rolled back rather than left holding the lease without its claims. `queue`: the conflicting claim is accepted as `state: "waiting"` instead of failing; it is promoted automatically once the conflict clears (release, finish, or the next orboto_work_sessions read).'),
223
+ .describe('reject rolls back a new session on claim conflict; queue keeps it with waiting claims, promoted when conflicts clear.'),
212
224
  }).shape,
213
225
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
214
226
  };
@@ -221,6 +233,8 @@ export function makeWorkSessionStartHandler(client) {
221
233
  ticketId: ticket.id,
222
234
  ...(args.role ? { role: args.role } : {}),
223
235
  ...(args.leaseSeconds ? { leaseSeconds: args.leaseSeconds } : {}),
236
+ ...(args.override !== undefined ? { override: args.override } : {}),
237
+ ...(args.reason ? { reason: args.reason } : {}),
224
238
  ...(args.takeover ? { takeover: true } : {}),
225
239
  ...(args.startTimer !== undefined ? { startTimer: args.startTimer } : {}),
226
240
  ...(args.resourceClaims && args.resourceClaims.length > 0 ? { resourceClaims: args.resourceClaims } : {}),
@@ -248,6 +262,9 @@ export function makeWorkSessionStartHandler(client) {
248
262
  }
249
263
  catch (err) {
250
264
  if (err instanceof OrbotoApiError && err.status === 409) {
265
+ const specFailure = specGateFailure(err.body);
266
+ if (specFailure)
267
+ return specFailure;
251
268
  const claimConflicts = parseClaimConflicts(err);
252
269
  if (claimConflicts && claimConflicts.length > 0) {
253
270
  return {
@@ -294,6 +311,8 @@ export const workSessionFinishToolConfig = {
294
311
  .describe('`finished` (default) = the work completed. `cancelled` = abandoned attempt; time is still booked, history stays honest.'),
295
312
  commitSha: z.string().optional().describe('The commit this session produced, when it produced one.'),
296
313
  verification: z.object({
314
+ reviewOfSessionId: z.string().uuid().optional().describe('Exact independent implementation work session this review examined.'),
315
+ reviewVerdict: z.enum(['approved', 'rework']).optional().describe('Explicit review outcome, paired with reviewOfSessionId; never inferred from later ticket status.'),
297
316
  build: z.boolean().optional(),
298
317
  tests: z.boolean().optional(),
299
318
  lint: z.boolean().optional(),
@@ -327,6 +346,8 @@ export const workFinishToolConfig = {
327
346
  .describe('`finished` (default) drives the ticket transition below. `cancelled` = abandoned attempt; time is still booked, the ticket is left untouched.'),
328
347
  commitSha: z.string().optional().describe('The commit this session produced. Recorded as an attestation immediately, verified asynchronously once git ingestion catches up - never blocks this call.'),
329
348
  verification: z.object({
349
+ reviewOfSessionId: z.string().uuid().optional().describe('Exact independent implementation work session this review examined.'),
350
+ reviewVerdict: z.enum(['approved', 'rework']).optional().describe('Explicit review outcome, paired with reviewOfSessionId; never inferred from later ticket status.'),
330
351
  build: z.boolean().optional(),
331
352
  tests: z.boolean().optional(),
332
353
  lint: z.boolean().optional(),
@@ -377,7 +398,7 @@ export function makeWorkFinishHandler(client) {
377
398
  }
378
399
  export const workSessionsToolConfig = {
379
400
  title: 'List live work sessions (who is working on what)',
380
- description: 'The coordination view: every live work lease you can see, or the sessions on one ticket. Call this BEFORE picking up work in a fleet - a ticket with a live `implementation` lease is already being worked, and starting on it anyway is how two agents produce conflicting commits. Unlike agent presence (which reports who is online), this reports who OWNS what, across accounts.',
401
+ description: 'Visible work leases and specification tasks waiting for ticket replies. Check before picking up work. An active implementation lease owns the ticket; a finished session with waitingForAnswers is parked, not a live lease. Includes task and question ids across accounts.',
381
402
  inputSchema: z.object({
382
403
  ticketKey: z.string().optional().describe('Scope to one ticket. Omit to list every live session you can see.'),
383
404
  scope: z.enum(['mine', 'all']).optional().describe('`mine` = only my own sessions. Default `all`. Ignored when ticketKey is set.'),
@@ -351,3 +351,17 @@ describe('ORB-1610 - orboto_work_session_claims_release', () => {
351
351
  expect(res.content[0].text).toContain('Released every claim');
352
352
  });
353
353
  });
354
+ describe('review evidence binding', () => {
355
+ it('forwards the explicit implementation reference and verdict through both finish surfaces', async () => {
356
+ const verification = { reviewOfSessionId: '11111111-1111-4111-8111-111111111111', reviewVerdict: 'rework', tests: false };
357
+ const calls = stub([
358
+ { json: { session: { ...SESSION, status: 'finished' }, durationMinutes: 0, changed: true } },
359
+ { json: { session: { ...SESSION, status: 'finished' }, durationMinutes: 0, changed: true } },
360
+ ]);
361
+ await makeWorkSessionFinishHandler(client)({ sessionId: SESSION.id, verification });
362
+ await makeWorkFinishHandler(client)({ sessionId: SESSION.id, verification });
363
+ expect(calls).toHaveLength(2);
364
+ for (const call of calls)
365
+ expect(call.body?.verification).toEqual(verification);
366
+ });
367
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orboto/mcp",
3
- "version": "0.195.0",
3
+ "version": "0.197.0",
4
4
  "description": "Model Context Protocol server for orboto - exposes tickets, projects, milestones, time, docs, primer-facts, alerts and absences as MCP tools for Claude Desktop / Cursor / Copilot Chat / any MCP-aware AI client.",
5
5
  "license": "MIT",
6
6
  "type": "module",