@orboto/mcp 0.195.1 → 0.198.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 (43) 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 +11 -5
  5. package/dist/orboto-client.js +4 -4
  6. package/dist/server.js +10 -0
  7. package/dist/tool-docs.js +1 -1
  8. package/dist/tools/agent-coordination.js +33 -32
  9. package/dist/tools/agent-coordination.test.js +87 -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 +32 -15
  13. package/dist/tools/agent-presence-schema.js +40 -0
  14. package/dist/tools/agent-session-scope.js +8 -0
  15. package/dist/tools/claim.js +22 -19
  16. package/dist/tools/claim.test.js +7 -5
  17. package/dist/tools/get-ticket.js +3 -0
  18. package/dist/tools/git-rotate-token.js +61 -0
  19. package/dist/tools/git-rotate-token.test.js +87 -0
  20. package/dist/tools/list-git-app-installations.js +1 -10
  21. package/dist/tools/list-tickets.js +4 -0
  22. package/dist/tools/project-readiness-schema.js +16 -0
  23. package/dist/tools/project-readiness.js +16 -0
  24. package/dist/tools/project-readiness.test.js +19 -0
  25. package/dist/tools/raci.js +4 -2
  26. package/dist/tools/sentry.js +85 -0
  27. package/dist/tools/sentry.test.js +107 -0
  28. package/dist/tools/session-start.js +20 -8
  29. package/dist/tools/session-start.test.js +44 -0
  30. package/dist/tools/shared.js +9 -0
  31. package/dist/tools/spec-check.js +15 -0
  32. package/dist/tools/spec-check.test.js +23 -0
  33. package/dist/tools/spec-readiness.test.js +85 -0
  34. package/dist/tools/spec-schemas.js +34 -0
  35. package/dist/tools/ticket-writes.js +27 -22
  36. package/dist/tools/ticket-writes.test.js +3 -4
  37. package/dist/tools/update-project.js +19 -12
  38. package/dist/tools/update-project.test.js +13 -4
  39. package/dist/tools/work-next.js +1 -1
  40. package/dist/tools/work-sessions-next.test.js +23 -0
  41. package/dist/tools/work-sessions.js +30 -9
  42. package/dist/tools/work-sessions.test.js +14 -0
  43. package/package.json +1 -1
@@ -0,0 +1,85 @@
1
+ /**
2
+ * ORB-2123 - `orboto_sentry`: the Sentry connector in one tool. Mapping
3
+ * CRUD, health, secret rotation, the triage-lane binding and the lane's
4
+ * verdict write share an `action` so the full manifest stays under its
5
+ * shrink-only ceiling.
6
+ */
7
+ import { z } from 'zod';
8
+ import { mcpInstanceToken, resolveProjectByKey, resolveTicketByKey } from './shared.js';
9
+ const ACTIONS = ['list', 'health', 'connect', 'update', 'rotate_secret', 'disconnect', 'triage_lane', 'verdict'];
10
+ export const sentryToolConfig = {
11
+ title: 'Sentry connector',
12
+ description: 'Sentry connector (ORB-2123): list, health, connect (orgSlug, projectSlug, clientSecret), update (triageMode comment|fix_branch), rotate_secret, disconnect, triage_lane (laneId, empty unbinds), verdict (ticketKey, verdict known_fixed|regression|new, summary). Secrets never come back; docs/sentry-intake.md.',
13
+ inputSchema: z.object({
14
+ action: z.enum(ACTIONS),
15
+ projectKey: z.string().min(1),
16
+ connectionId: z.string().uuid().optional(),
17
+ orgSlug: z.string().optional(),
18
+ projectSlug: z.string().optional(),
19
+ clientSecret: z.string().optional(),
20
+ triageMode: z.enum(['comment', 'fix_branch']).optional(),
21
+ laneId: z.string().optional(),
22
+ ticketKey: z.string().optional(),
23
+ verdict: z.enum(['known_fixed', 'regression', 'new']).optional(),
24
+ summary: z.string().optional(),
25
+ }).shape,
26
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
27
+ };
28
+ function line(c) {
29
+ return `- ${c.id} ${c.orgSlug}/${c.projectSlug} mode=${c.triageMode} ${c.enabled ? 'enabled' : 'paused'} token=${c.hasAuthToken ? 'yes' : 'no'} deliveries=${c.webhookCount} last=${c.lastWebhookAt ?? 'never'}${c.lastError ? ` error=${c.lastError}` : ''}\n webhook ${c.webhookUrl}`;
30
+ }
31
+ function text(result, summary) {
32
+ return { content: [{ type: 'text', text: summary }], structuredContent: result };
33
+ }
34
+ export function makeSentryHandler(client) {
35
+ return async (args) => {
36
+ const project = await resolveProjectByKey(client, args.projectKey);
37
+ const need = (field) => { if (args[field] === undefined || args[field] === null || args[field] === '')
38
+ throw new Error(`${args.action} needs ${String(field)}.`); };
39
+ if (args.action === 'list') {
40
+ const rows = await client.get(`/projects/${project.id}/sentry-connections`);
41
+ return text({ connections: rows }, rows.length ? rows.map(line).join('\n') : `No Sentry connection on ${project.key}.`);
42
+ }
43
+ if (args.action === 'connect') {
44
+ need('orgSlug');
45
+ need('projectSlug');
46
+ need('clientSecret');
47
+ const created = await client.post(`/projects/${project.id}/sentry-connections`, {
48
+ orgSlug: args.orgSlug, projectSlug: args.projectSlug, clientSecret: args.clientSecret,
49
+ ...(args.triageMode ? { triageMode: args.triageMode } : {}),
50
+ });
51
+ const { webhookUrlWarning, ...connection } = created;
52
+ return text({ connection }, `Connected ${created.orgSlug}/${created.projectSlug} to ${project.key} (${created.id}).\n${line(created)}${webhookUrlWarning ? `\nWarning: ${webhookUrlWarning.message}` : ''}`);
53
+ }
54
+ need('connectionId');
55
+ if (args.action === 'health') {
56
+ const health = await client.get(`/projects/${project.id}/sentry-connections/${args.connectionId}/health`);
57
+ return text(health, `${health.healthy ? 'healthy' : 'needs attention'} - deliveries=${health.webhookCount} last=${health.lastWebhookAt ?? 'never'} linked=${health.linkedIssues} open=${health.openLinkedIssues}${health.lastError ? ` error=${health.lastError}` : ''}`);
58
+ }
59
+ if (args.action === 'update') {
60
+ need('triageMode');
61
+ const row = await client.patch(`/projects/${project.id}/sentry-connections/${args.connectionId}`, { triageMode: args.triageMode });
62
+ return text({ connection: row }, `Triage mode is now ${row.triageMode}.\n${line(row)}`);
63
+ }
64
+ if (args.action === 'rotate_secret') {
65
+ need('clientSecret');
66
+ const row = await client.post(`/projects/${project.id}/sentry-connections/${args.connectionId}/rotate-secret`, { clientSecret: args.clientSecret });
67
+ return text({ connection: row }, `Client secret replaced on ${row.id}; deliveries signed with the old secret are rejected from now on.`);
68
+ }
69
+ if (args.action === 'disconnect') {
70
+ await client.delete(`/projects/${project.id}/sentry-connections/${args.connectionId}`);
71
+ return text({ deleted: true, connectionId: args.connectionId }, `Disconnected ${args.connectionId} from ${project.key}.`);
72
+ }
73
+ if (args.action === 'triage_lane') {
74
+ const out = await client.put(`/projects/${project.id}/sentry-connections/${args.connectionId}/triage-lane`, { laneId: args.laneId || null });
75
+ return text(out, out.lane ? `Lane ${out.lane.name} bound (assignment v${out.lane.assignmentVersion}); mandate follows mode ${out.connection.triageMode}.` : 'Triage lane unbound; the mandate was withdrawn.');
76
+ }
77
+ need('ticketKey');
78
+ need('verdict');
79
+ need('summary');
80
+ const ticket = await resolveTicketByKey(client, args.ticketKey);
81
+ const body = { verdict: args.verdict, summary: args.summary, agentSessionToken: mcpInstanceToken() };
82
+ const result = await client.post(`/projects/${project.id}/sentry-connections/${args.connectionId}/triage/${ticket.id}/verdict`, body);
83
+ return text(result, `${result.ticketKey ?? ticket.id}: ${result.verdict} (${result.mode}) priority=${result.priority ?? '-'}${result.closed ? ' closed' : ''}${result.escalated ? ' escalated' : ''}${result.branchName ? ` branch=${result.branchName} session=${result.workSessionId}` : ''} next=${result.nextStep}`);
84
+ };
85
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * ORB-2123 - `orboto_sentry` handler tests: every action hits the right
3
+ * route, secrets never come back in the text, and a missing field fails
4
+ * before any write.
5
+ */
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
+ import { OrbotoClient } from '../orboto-client.js';
8
+ import { makeSentryHandler, sentryToolConfig } from './sentry.js';
9
+ beforeEach(() => { vi.restoreAllMocks(); });
10
+ afterEach(() => { vi.restoreAllMocks(); });
11
+ function stub(responses) {
12
+ const calls = [];
13
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
14
+ const u = url.toString();
15
+ const m = init?.method ?? 'GET';
16
+ const b = init?.body ? JSON.parse(init.body) : undefined;
17
+ calls.push({ url: u, method: m, body: b });
18
+ const r = responses.shift();
19
+ if (!r)
20
+ throw new Error(`unexpected extra fetch ${m} ${u}`);
21
+ return {
22
+ ok: r.ok ?? true,
23
+ status: r.status ?? 200,
24
+ statusText: r.ok === false ? 'Error' : 'OK',
25
+ json: async () => ('json' in r ? r.json : {}),
26
+ text: async () => JSON.stringify('json' in r ? r.json : {}),
27
+ };
28
+ });
29
+ return calls;
30
+ }
31
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
32
+ const project = { id: '11111111-1111-4111-8111-111111111111', key: 'TEN', name: '10monkeys' };
33
+ const connectionId = '22222222-2222-4222-8222-222222222222';
34
+ const connection = {
35
+ id: connectionId, orgSlug: 'edyoutec', projectSlug: '10monkeys-next', enabled: true, triageMode: 'comment', hasAuthToken: false,
36
+ webhookUrl: 'https://orboto.example.com/inbound/sentry/' + connectionId, lastWebhookAt: null, lastSignatureOk: null, lastError: null, webhookCount: 0,
37
+ triageLaneId: null, dailyTriageLimit: null, escalationUserId: null,
38
+ };
39
+ describe('orboto_sentry', () => {
40
+ it('declares a short title and explicit annotations', () => {
41
+ expect(sentryToolConfig.title.length).toBeLessThanOrEqual(64);
42
+ expect(sentryToolConfig.annotations).toEqual({ readOnlyHint: false, destructiveHint: true, idempotentHint: false });
43
+ });
44
+ it('lists connections with their webhook URL and health', async () => {
45
+ stub([{ json: project }, { json: [connection] }]);
46
+ const out = await makeSentryHandler(client)({ action: 'list', projectKey: 'TEN' });
47
+ const text = out.content[0].text;
48
+ expect(text).toContain('edyoutec/10monkeys-next mode=comment enabled token=no');
49
+ expect(text).toContain(connection.webhookUrl);
50
+ expect(out.structuredContent).toEqual({ connections: [connection] });
51
+ });
52
+ it('connects with the client secret in the body and never in the text', async () => {
53
+ const calls = stub([{ json: project }, { json: { ...connection, triageMode: 'fix_branch', webhookUrlWarning: { code: 'webhook_url_from_request', message: 'set API_PUBLIC_URL' } } }]);
54
+ const out = await makeSentryHandler(client)({ action: 'connect', projectKey: 'TEN', orgSlug: 'edyoutec', projectSlug: '10monkeys-next', clientSecret: 'sec_value_123', triageMode: 'fix_branch' });
55
+ expect(calls[1]).toMatchObject({ method: 'POST', url: expect.stringContaining(`/projects/${project.id}/sentry-connections`), body: { orgSlug: 'edyoutec', projectSlug: '10monkeys-next', clientSecret: 'sec_value_123', triageMode: 'fix_branch' } });
56
+ const text = out.content[0].text;
57
+ expect(text).toContain('Connected edyoutec/10monkeys-next to TEN');
58
+ expect(text).toContain('Warning: set API_PUBLIC_URL');
59
+ expect(text).not.toContain('sec_value_123');
60
+ expect(JSON.stringify(out.structuredContent)).not.toContain('sec_value_123');
61
+ });
62
+ it('refuses a connect without the secret before any write', async () => {
63
+ const calls = stub([{ json: project }]);
64
+ await expect(makeSentryHandler(client)({ action: 'connect', projectKey: 'TEN', orgSlug: 'edyoutec', projectSlug: 'p' })).rejects.toThrow('connect needs clientSecret.');
65
+ expect(calls).toHaveLength(1);
66
+ });
67
+ it('routes health, update, rotate_secret, disconnect and triage_lane to their endpoints', async () => {
68
+ const calls = stub([
69
+ { json: project }, { json: { healthy: true, lastWebhookAt: '2026-09-14T08:00:00Z', lastError: null, linkedIssues: 3, openLinkedIssues: 1, webhookCount: 9 } },
70
+ { json: project }, { json: { ...connection, triageMode: 'fix_branch' } },
71
+ { json: project }, { json: connection },
72
+ { json: project }, { json: { deleted: true } },
73
+ { json: project }, { json: { connection, lane: { id: 'l1', name: 'sentry-triage-1', assignmentVersion: 3 } } },
74
+ { json: project }, { json: { connection, lane: null } },
75
+ ]);
76
+ const h = makeSentryHandler(client);
77
+ expect((await h({ action: 'health', projectKey: 'TEN', connectionId })).content[0]).toMatchObject({ text: expect.stringContaining('healthy - deliveries=9') });
78
+ expect((await h({ action: 'update', projectKey: 'TEN', connectionId, triageMode: 'fix_branch' })).content[0]).toMatchObject({ text: expect.stringContaining('Triage mode is now fix_branch') });
79
+ expect((await h({ action: 'rotate_secret', projectKey: 'TEN', connectionId, clientSecret: 'new_secret_value' })).content[0]).toMatchObject({ text: expect.not.stringContaining('new_secret_value') });
80
+ expect((await h({ action: 'disconnect', projectKey: 'TEN', connectionId })).structuredContent).toEqual({ deleted: true, connectionId });
81
+ expect((await h({ action: 'triage_lane', projectKey: 'TEN', connectionId, laneId: 'l1' })).content[0]).toMatchObject({ text: expect.stringContaining('Lane sentry-triage-1 bound (assignment v3)') });
82
+ expect((await h({ action: 'triage_lane', projectKey: 'TEN', connectionId })).content[0]).toMatchObject({ text: expect.stringContaining('unbound') });
83
+ expect(calls.map((c) => `${c.method} ${c.url.replace('https://orboto.example.com', '')}`)).toEqual([
84
+ 'GET /projects/by-key/TEN', `GET /projects/${project.id}/sentry-connections/${connectionId}/health`,
85
+ 'GET /projects/by-key/TEN', `PATCH /projects/${project.id}/sentry-connections/${connectionId}`,
86
+ 'GET /projects/by-key/TEN', `POST /projects/${project.id}/sentry-connections/${connectionId}/rotate-secret`,
87
+ 'GET /projects/by-key/TEN', `DELETE /projects/${project.id}/sentry-connections/${connectionId}`,
88
+ 'GET /projects/by-key/TEN', `PUT /projects/${project.id}/sentry-connections/${connectionId}/triage-lane`,
89
+ 'GET /projects/by-key/TEN', `PUT /projects/${project.id}/sentry-connections/${connectionId}/triage-lane`,
90
+ ]);
91
+ expect(calls[9].body).toEqual({ laneId: 'l1' });
92
+ expect(calls[11].body).toEqual({ laneId: null });
93
+ });
94
+ it('records a verdict on the ticket resolved by key and reports the hand-off', async () => {
95
+ const calls = stub([
96
+ { json: project },
97
+ { json: project },
98
+ { json: { id: '33333333-3333-4333-8333-333333333333', ticketKey: 'TEN-7', title: 'x' } },
99
+ { json: { ticketKey: 'TEN-7', verdict: 'new', mode: 'fix_branch', closed: false, escalated: false, branchName: 'sentry/ten-7-fix', workSessionId: 'ws1', nextStep: 'implement_then_finish', priority: 'high' } },
100
+ ]);
101
+ const out = await makeSentryHandler(client)({ action: 'verdict', projectKey: 'TEN', connectionId, ticketKey: 'TEN-7', verdict: 'new', summary: 'Unguarded read of session.cart.' });
102
+ expect(calls[3]).toMatchObject({ method: 'POST', url: expect.stringContaining(`/sentry-connections/${connectionId}/triage/33333333-3333-4333-8333-333333333333/verdict`) });
103
+ expect(calls[3].body).toMatchObject({ verdict: 'new', summary: 'Unguarded read of session.cart.' });
104
+ expect(typeof calls[3].body.agentSessionToken).toBe('string');
105
+ expect(out.content[0].text).toBe('TEN-7: new (fix_branch) priority=high branch=sentry/ten-7-fix session=ws1 next=implement_then_finish');
106
+ });
107
+ });
@@ -9,14 +9,15 @@
9
9
  * @see ORB-1605, ORB-1607, ORB-1818
10
10
  */
11
11
  import { z } from 'zod';
12
- import { resolveTicketByKey, applyAgentProfile } from './shared.js';
12
+ import { resolveTicketByKey, applyAgentProfile, mcpInstanceToken } from './shared.js';
13
+ import { AgentSessionScopeSchema } from './agent-session-scope.js';
13
14
  import { PROTECT_TEXT_META, storePayload } from '../response-budget.js';
14
15
  import { loadRequiredRules } from '../required-rules.js';
15
16
  import { GIT_HEALTH_REASON_TEXT } from './git-health-reasons.js';
16
17
  export const sessionStartToolConfig = {
17
18
  title: 'Load the rules you must follow + re-orient',
18
- description: 'THE canonical way to LOAD the binding workspace rules you must follow as an agent. Run it as your FIRST action in a session and immediately AFTER any context compaction. Returns a one-line-per-rule INDEX of the binding working-rules plus their hash (or a compact "unchanged" ack on a repeat call within the same connection) - read the index, then call this tool again with `rulesOnly: true` to read the full text of the rules whenever you do not already hold that exact hash, and expand before acting on any rule whose title touches what you are about to do. Also returns your in-progress tickets - each flagged LANDED, IDLE when it has a linked commit but has not moved for days, i.e. finished work you never handed to review - your running timer, and a warning if a project\'s git connection looks unhealthy (commit ingestion may be stalled). Pass `ticketKey` to also get a one-shot bundle for that ticket: project primer, the full ticket with dependencies + checklists, that project\'s git health, and any other agent sessions currently on it - replacing several separate calls. (Do NOT use orboto_list_agent_instructions to read the rules - that tool MANAGES/edits rule blocks for admins; this one is what you read to know how to work.) Read-only; no side effects. '
19
- + '**Parameters.** `rulesOnly: true` returns ONLY the complete rules text (nothing else) and is never truncated - the cheapest way to read the rules the index listed. `projectId` adds that project\'s rules on top of the workspace + personal ones. `ticketKey` ("ACME-42") bundles that ticket\'s primer, full detail, dependencies, checklists, git health and other active agent sessions into the same response. `forceRules: true` returns the full rules text inline with the rest of the digest even when this connection already delivered them - use it whenever the rules are NOT in your context right now: after a compaction, a /clear, or a fresh agent taking over an existing connection. `agentKind` (coding, orchestrator, reviewer, runner) and `modelTier` (frontier, standard, small) are your self-declared classification; rule blocks and per-tier rule text are targeted by them.',
19
+ description: 'THE canonical way to LOAD the binding workspace rules you must follow as an agent. Run it as your FIRST action in a session and immediately AFTER any context compaction. Returns a one-line-per-rule INDEX of the binding working-rules plus their hash (or a compact "unchanged" ack on a repeat call within the same connection) - read the index, then call this tool again with `rulesOnly: true` to read the full text of the rules whenever you do not already hold that exact hash, and expand before acting on any rule whose title touches what you are about to do. Also returns your in-progress tickets - each flagged LANDED, IDLE when it has a linked commit but has not moved for days, i.e. finished work you never handed to review - your running timer, and a warning if a project\'s git connection looks unhealthy (commit ingestion may be stalled). Pass `ticketKey` to also get a one-shot bundle for that ticket: project primer, the full ticket with dependencies + checklists, that project\'s git health, and any other agent sessions currently on it - replacing several separate calls. (Do NOT use orboto_list_agent_instructions to read the rules - that tool MANAGES/edits rule blocks for admins; this one is what you read to know how to work.) Registers this session (a heartbeat on its instance row) and is otherwise read-only. '
20
+ + '**Parameters.** `rulesOnly: true` returns ONLY the complete rules text (nothing else) and is never truncated - the cheapest way to read the rules the index listed. `projectId` adds that project\'s rules on top of the workspace + personal ones. `ticketKey` ("ACME-42") bundles that ticket\'s primer, full detail, dependencies, checklists, git health and other active agent sessions into the same response. `forceRules: true` returns the full rules text inline with the rest of the digest even when this connection already delivered them - use it whenever the rules are NOT in your context right now: after a compaction, a /clear, or a fresh agent taking over an existing connection. `agentKind` (coding, orchestrator, reviewer, runner) and `modelTier` (frontier, standard, small) are your self-declared classification; rule blocks and per-tier rule text are targeted by them. `scope` { projectKeys, ticketKeys, role } declares this session\'s responsibility; the returned session.ref is what peers pass as toSessionRef (ORB-2136).',
20
21
  inputSchema: z.object({
21
22
  projectId: z.string().uuid().optional().describe('Also load this project\'s rules.'),
22
23
  ticketKey: z.string().min(3).optional().describe('Ticket key ("ACME-42") - bundles that ticket\'s full context.'),
@@ -24,8 +25,9 @@ export const sessionStartToolConfig = {
24
25
  rulesOnly: z.boolean().optional().describe('Return ONLY the complete rules text.'),
25
26
  agentKind: z.string().min(1).max(32).optional().describe('coding | orchestrator | reviewer | runner.'),
26
27
  modelTier: z.string().min(1).max(32).optional().describe('frontier | standard | small.'),
28
+ scope: AgentSessionScopeSchema.optional(),
27
29
  }).shape,
28
- annotations: { readOnlyHint: true, idempotentHint: true },
30
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
29
31
  };
30
32
  const MAX_GIT_HEALTH_PROJECTS = 8;
31
33
  /** ORB-1607 - build the `--ticket` one-shot bundle. Never throws: an
@@ -128,7 +130,8 @@ async function buildTicketBundle(client, ticketKey) {
128
130
  }
129
131
  export function makeSessionStartHandler(client) {
130
132
  let lastKnownRulesHash;
131
- return async (input = {}) => {
133
+ return async (input = {}, extra) => {
134
+ const instanceToken = mcpInstanceToken(undefined, extra);
132
135
  const rulesParams = new URLSearchParams();
133
136
  if (input.projectId)
134
137
  rulesParams.set('projectId', input.projectId);
@@ -163,16 +166,19 @@ export function makeSessionStartHandler(client) {
163
166
  },
164
167
  };
165
168
  }
169
+ const registration = await client.post('/v1/agent/heartbeat', input.scope !== undefined ? { scope: input.scope } : {}, { instanceToken })
170
+ .then((r) => (r && typeof r.sessionId === 'string' ? { sessionId: r.sessionId, scope: r.scope ?? null } : null))
171
+ .catch(() => null);
166
172
  const [me, rules, assigned, timer, inboxRaw] = await Promise.all([
167
173
  client.get('/users/me').catch(() => null),
168
174
  loadRequiredRules(client, rulesPath, rulesParams.get('knownRulesHash') ?? undefined),
169
175
  client.get('/users/me/assigned-tickets?statuses=IN_PROGRESS,IN_REVIEW&limit=20').catch(() => ({ items: [] })),
170
176
  client.get('/time/timer').catch(() => null),
171
- client.get('/v1/agent/messages?limit=10')
177
+ client.get('/v1/agent/messages?limit=10', { instanceToken })
172
178
  .then((r) => (Array.isArray(r?.messages) ? r.messages : []))
173
179
  .catch(() => []),
174
180
  ]);
175
- const pendingMessages = inboxRaw.map((m) => ({ id: m.id, fromUserId: m.fromUserId, kind: m.kind, subject: m.subject, createdAt: m.createdAt }));
181
+ const pendingMessages = inboxRaw.map((m) => ({ id: m.id, fromUserId: m.fromUserId, from: m.from?.label ?? m.fromUserId, kind: m.kind, subject: m.subject, createdAt: m.createdAt }));
176
182
  const tickets = Array.isArray(assigned) ? assigned : (assigned?.items ?? []);
177
183
  const projectIds = [...new Set(tickets.map((t) => t.projectId).filter((id) => !!id))].slice(0, MAX_GIT_HEALTH_PROJECTS);
178
184
  const gitHealthByProject = await Promise.all(projectIds.map(async (projectId) => ({
@@ -245,10 +251,15 @@ export function makeSessionStartHandler(client) {
245
251
  }
246
252
  lines.push('', '## Timer');
247
253
  lines.push(timer?.ticketId ? `Running on ${timer.ticketKey ?? timer.ticketId} since ${timer.startedAt ?? 'earlier'}.` : 'No timer running.');
254
+ lines.push('', '## This session');
255
+ const scopeText = registration?.scope
256
+ ? [registration.scope.role ? `role ${registration.scope.role}` : null, registration.scope.projectKeys?.length ? `projects ${registration.scope.projectKeys.join(', ')}` : null, registration.scope.ticketKeys?.length ? `tickets ${registration.scope.ticketKeys.join(', ')}` : null].filter(Boolean).join('; ')
257
+ : 'no scope declared - this session lists every account-addressed message; pass scope: { projectKeys, role } to narrow it';
258
+ lines.push(`ref ${instanceToken}${registration ? `, instance ${registration.sessionId.slice(0, 8)}` : ''}. ${scopeText}. Peers reach exactly this session with orboto_agent_notify { toSessionRef: "${registration ? registration.sessionId.slice(0, 8) : instanceToken}" }.`);
248
259
  if (pendingMessages.length > 0) {
249
260
  lines.push('', '## Agent messages - unread');
250
261
  for (const m of pendingMessages)
251
- lines.push(`- [${m.kind}] ${m.subject} (from ${m.fromUserId}, ${m.createdAt}, id ${m.id})`);
262
+ lines.push(`- [${m.kind}] ${m.subject} (from ${m.from}, ${m.createdAt}, id ${m.id})`);
252
263
  lines.push('Handle them, then acknowledge via orboto_messages { ackIds: [...] }; reply via orboto_agent_notify with threadId.');
253
264
  }
254
265
  if (bundle)
@@ -274,6 +285,7 @@ export function makeSessionStartHandler(client) {
274
285
  })),
275
286
  ...(elsewhereCount > 0 ? { inProgressElsewhereCount: elsewhereCount } : {}),
276
287
  timer: timer?.ticketId ? { ticketKey: timer.ticketKey ?? null, startedAt: timer.startedAt ?? null } : null,
288
+ session: { ref: instanceToken, id: registration?.sessionId ?? null, scope: registration?.scope ?? null },
277
289
  gitHealth: {
278
290
  unhealthy: gitHealthWithConnections
279
291
  .map((p) => ({ projectId: p.projectId, connections: p.connections.filter((c) => !c.healthy) }))
@@ -523,3 +523,47 @@ describe('ORB-1818 - rules index instead of the full rule text', () => {
523
523
  expect(res.content[0].text).toContain(RULES_TEXT);
524
524
  });
525
525
  });
526
+ describe('ORB-2136 - session scope and ref', () => {
527
+ it('registers the scope on this MCP session via heartbeat with the session header and reports the session ref', async () => {
528
+ const seen = [];
529
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
530
+ const u = new URL(url.toString());
531
+ const path = u.pathname + u.search;
532
+ seen.push({ path, headers: (init?.headers ?? {}), body: init?.body ? JSON.parse(init.body) : undefined });
533
+ const map = {
534
+ '/v1/agent/heartbeat': { sessionToken: 'mcp-abc123', sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: { role: 'integrator', projectKeys: ['ORB'] } },
535
+ '/users/me/assigned-tickets': { items: [] },
536
+ '/users/me': { email: 'dev@x.io', fullName: 'Dev' },
537
+ '/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
538
+ '/time/timer': {},
539
+ '/v1/agent/messages': { sessionId: 'cbb52195-0000-4000-8000-000000000000', messages: [{ id: 'm1', fromUserId: 'u1', from: { label: 'spec@orboto.io (spec, s1)' }, kind: 'request', subject: 'ticket-ready:ORB-1', createdAt: 'now' }] },
540
+ };
541
+ const key = Object.keys(map).find((k) => path.startsWith(k));
542
+ return { ok: true, status: 200, statusText: 'OK', json: async () => (key ? map[key] : {}), text: async () => '' };
543
+ });
544
+ const res = await makeSessionStartHandler(client)({ scope: { role: 'integrator', projectKeys: ['orb'] } }, { sessionId: 'abc123' });
545
+ const beat = seen.find((c) => c.path === '/v1/agent/heartbeat');
546
+ expect(beat?.headers['x-orboto-agent-session']).toBe('mcp-abc123');
547
+ expect(beat?.body).toEqual({ scope: { role: 'integrator', projectKeys: ['orb'] } });
548
+ expect(seen.find((c) => c.path.startsWith('/v1/agent/messages'))?.headers['x-orboto-agent-session']).toBe('mcp-abc123');
549
+ const text = res.content[0].text;
550
+ expect(text).toContain('ref mcp-abc123, instance cbb52195. role integrator; projects ORB');
551
+ expect(text).toContain('toSessionRef: "cbb52195"');
552
+ expect(text).toContain('from spec@orboto.io (spec, s1)');
553
+ expect(res.structuredContent).toMatchObject({ session: { ref: 'mcp-abc123', id: 'cbb52195-0000-4000-8000-000000000000', scope: { role: 'integrator', projectKeys: ['ORB'] } } });
554
+ });
555
+ it('without a scope the heartbeat body stays empty and the digest says no scope is declared', async () => {
556
+ const seen = [];
557
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
558
+ const u = new URL(url.toString());
559
+ seen.push({ path: u.pathname, body: init?.body ? JSON.parse(init.body) : undefined });
560
+ const body = u.pathname === '/v1/agent/heartbeat'
561
+ ? { sessionToken: 'x', sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: null }
562
+ : u.pathname === '/agent-instructions' ? { instructions: 'rules here', rulesHash: 'fixture' } : {};
563
+ return { ok: true, status: 200, statusText: 'OK', json: async () => body, text: async () => '' };
564
+ });
565
+ const res = await makeSessionStartHandler(client)({}, { sessionId: 'abc123' });
566
+ expect(seen.find((c) => c.path === '/v1/agent/heartbeat')?.body).toEqual({});
567
+ expect(res.content[0].text).toContain('no scope declared');
568
+ });
569
+ });
@@ -27,6 +27,8 @@ export function agentTicketListRow(t, verbose = false) {
27
27
  title: t.title,
28
28
  status: t.statusName ?? t.status,
29
29
  statusCategory: t.statusCategory ?? null,
30
+ specState: t.specState ?? 'none',
31
+ ...specReleaseInfo(t),
30
32
  priority: t.priority,
31
33
  type: t.type,
32
34
  dueDate: t.dueDate ?? null,
@@ -43,8 +45,10 @@ export function agentTicketListRow(t, verbose = false) {
43
45
  const assigneeNames = (t.assignees ?? []).map((a) => a.fullName || a.email);
44
46
  return {
45
47
  key: t.ticketKey,
48
+ ...specReleaseInfo(t),
46
49
  title: t.title,
47
50
  statusCategory: t.statusCategory ?? null,
51
+ ...(t.specState && t.specState !== 'none' ? { specState: t.specState } : {}),
48
52
  ...(t.priority && t.priority !== 'normal' ? { priority: t.priority } : {}),
49
53
  ...(t.type && t.type !== 'task' ? { type: t.type } : {}),
50
54
  ...(t.dueDate ? { dueDate: t.dueDate } : {}),
@@ -183,3 +187,8 @@ export function resolveByName(candidates, ref, getName) {
183
187
  return { match: null, ambiguous: normMatches };
184
188
  return { match: null, ambiguous: null };
185
189
  }
190
+ export function specReleaseInfo(t) {
191
+ if (t.specReleasedBy === undefined)
192
+ return {};
193
+ return { specReleasedBy: t.specReleasedBy, specReleasedByFullName: t.specReleasedByFullName ?? null, specReleasedAt: t.specReleasedAt ?? null, specReleasePolicy: t.specReleasePolicy ?? null, specReleaseRole: t.specReleaseRole ?? null };
194
+ }
@@ -0,0 +1,15 @@
1
+ import { z } from 'zod';
2
+ import { resolveTicketByKey } from './shared.js';
3
+ export const specCheckToolConfig = {
4
+ title: 'Check build order',
5
+ description: 'Validate a build order. Returns issues, template, human authorId and releaseBy policy. Release permission is checked separately.',
6
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
7
+ inputSchema: z.object({ ticketKey: z.string().min(1) }),
8
+ };
9
+ export function makeSpecCheckHandler(client) {
10
+ return async (input) => {
11
+ const ticket = await resolveTicketByKey(client, input.ticketKey);
12
+ const result = await client.get(`/projects/${ticket.projectId}/tickets/${ticket.id}/spec-check`);
13
+ return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
14
+ };
15
+ }
@@ -0,0 +1,23 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { OrbotoClient, OrbotoApiError } from '../orboto-client.js';
3
+ import { makeSpecCheckHandler, specCheckToolConfig } from './spec-check.js';
4
+ afterEach(() => { vi.restoreAllMocks(); });
5
+ describe('spec check', () => {
6
+ it('returns the authoritative diagnostics and template without writes', async () => {
7
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.test', apiKey: 'test' });
8
+ const report = { authorId: 'human-author', releaseBy: 'author', check: { valid: false, criteriaCount: 0, openQuestionCount: 2, missingHeadings: ['Goal'] }, template: 'template' };
9
+ const get = vi.spyOn(client, 'get').mockResolvedValueOnce({ id: 'p', key: 'ORB' }).mockResolvedValueOnce({ id: 't', projectId: 'p' }).mockResolvedValueOnce(report);
10
+ const post = vi.spyOn(client, 'post');
11
+ const patch = vi.spyOn(client, 'patch');
12
+ expect(await makeSpecCheckHandler(client)({ ticketKey: 'ORB-2099' })).toMatchObject({ structuredContent: report });
13
+ expect(get).toHaveBeenLastCalledWith('/projects/p/tickets/t/spec-check');
14
+ expect(post).not.toHaveBeenCalled();
15
+ expect(patch).not.toHaveBeenCalled();
16
+ expect(specCheckToolConfig.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false });
17
+ });
18
+ it('propagates read authorization errors', async () => {
19
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.test', apiKey: 'test' });
20
+ vi.spyOn(client, 'get').mockResolvedValueOnce({ id: 'p', key: 'ORB' }).mockResolvedValueOnce({ id: 't', projectId: 'p' }).mockRejectedValueOnce(new OrbotoApiError(404, '{}', 'test'));
21
+ await expect(makeSpecCheckHandler(client)({ ticketKey: 'ORB-2099' })).rejects.toMatchObject({ status: 404 });
22
+ });
23
+ });
@@ -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
+ }