@orboto/mcp 0.195.1 → 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.
- package/dist/event-bridge.js +5 -0
- package/dist/event-bridge.test.js +34 -0
- package/dist/http-transport.js +6 -3
- package/dist/manifest-size.test.js +4 -2
- package/dist/orboto-client.js +1 -1
- package/dist/server.js +10 -0
- package/dist/tool-docs.js +1 -1
- package/dist/tools/agent-coordination.js +17 -23
- package/dist/tools/agent-coordination.test.js +40 -6
- package/dist/tools/agent-message-work.js +22 -0
- package/dist/tools/agent-message-work.test.js +33 -0
- package/dist/tools/agent-messages.js +18 -8
- package/dist/tools/agent-presence-schema.js +35 -0
- package/dist/tools/claim.js +22 -19
- package/dist/tools/claim.test.js +7 -5
- package/dist/tools/get-ticket.js +3 -0
- package/dist/tools/git-rotate-token.js +61 -0
- package/dist/tools/git-rotate-token.test.js +87 -0
- package/dist/tools/list-git-app-installations.js +1 -10
- package/dist/tools/list-tickets.js +4 -0
- package/dist/tools/project-readiness-schema.js +16 -0
- package/dist/tools/project-readiness.js +16 -0
- package/dist/tools/project-readiness.test.js +19 -0
- package/dist/tools/raci.js +4 -2
- package/dist/tools/sentry.js +85 -0
- package/dist/tools/sentry.test.js +107 -0
- package/dist/tools/shared.js +9 -0
- package/dist/tools/spec-check.js +15 -0
- package/dist/tools/spec-check.test.js +23 -0
- package/dist/tools/spec-readiness.test.js +85 -0
- package/dist/tools/spec-schemas.js +34 -0
- package/dist/tools/ticket-writes.js +27 -22
- package/dist/tools/ticket-writes.test.js +3 -4
- package/dist/tools/update-project.js +19 -12
- package/dist/tools/update-project.test.js +13 -4
- package/dist/tools/work-next.js +1 -1
- package/dist/tools/work-sessions-next.test.js +23 -0
- package/dist/tools/work-sessions.js +30 -9
- package/dist/tools/work-sessions.test.js +14 -0
- 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()
|
|
59
|
-
priority: z.enum(['blocker', 'high', 'normal', 'low', 'trivial']).optional()
|
|
60
|
-
|
|
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()
|
|
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()
|
|
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('
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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('
|
|
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
|
|
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
|
|
28
|
+
.describe('Customer UUID; null detaches.'),
|
|
26
29
|
language: z.enum(LOCALE_CODES).nullable().optional()
|
|
27
|
-
.describe('
|
|
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
|
|
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('
|
|
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('
|
|
60
|
-
description: z.string().nullable().optional().describe('
|
|
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('
|
|
66
|
+
.describe('Customer UUID.'),
|
|
63
67
|
language: z.enum(LOCALE_CODES).nullable().optional()
|
|
64
|
-
.describe('
|
|
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',
|
package/dist/tools/work-next.js
CHANGED
|
@@ -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: '
|
|
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
|
|
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
|
|
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('
|
|
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('
|
|
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: '
|
|
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.
|
|
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",
|