@orboto/mcp 0.226.2 → 0.228.1
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/index.js +13 -2
- package/dist/manifest-size.test.js +3 -0
- package/dist/project-scope.js +15 -1
- package/dist/project-scope.test.js +9 -9
- package/dist/response-budget.js +1 -1
- package/dist/server.js +5 -0
- package/dist/session-origin.js +23 -3
- package/dist/tools/agent-coordination.js +101 -36
- package/dist/tools/agent-coordination.test.js +62 -22
- package/dist/tools/agent-instructions.js +5 -4
- package/dist/tools/agent-messages-compact.test.js +3 -3
- package/dist/tools/agent-messages.js +24 -4
- package/dist/tools/agent-presence-row.js +22 -0
- package/dist/tools/agent-who.js +18 -18
- package/dist/tools/agent-who.test.js +9 -2
- package/dist/tools/bulk-writes.js +3 -3
- package/dist/tools/checklist-writes.js +2 -2
- package/dist/tools/claim.js +2 -2
- package/dist/tools/doc-attachments.js +4 -4
- package/dist/tools/doc-comments.js +5 -5
- package/dist/tools/doc-export.js +3 -3
- package/dist/tools/doc-revisions.js +3 -3
- package/dist/tools/docs.js +7 -7
- package/dist/tools/get-timer.js +6 -1
- package/dist/tools/identity.js +10 -1
- package/dist/tools/identity.test.js +11 -0
- package/dist/tools/list-git-app-installations.js +1 -1
- package/dist/tools/phase-b-tools.test.js +7 -0
- package/dist/tools/presence-scope.test.js +170 -0
- package/dist/tools/primer-facts.js +15 -15
- package/dist/tools/project-readiness-schema.js +1 -1
- package/dist/tools/read-budget.test.js +161 -0
- package/dist/tools/responsibilities.test.js +1 -1
- package/dist/tools/rule-proposals.js +131 -0
- package/dist/tools/rule-proposals.test.js +82 -0
- package/dist/tools/semantic-degraded-wrapper.test.js +1 -1
- package/dist/tools/session-start.js +52 -16
- package/dist/tools/session-start.test.js +8 -7
- package/dist/tools/shared.js +49 -0
- package/dist/tools/ticket-writes.js +2 -2
- package/dist/tools/wiki.js +2 -2
- package/dist/tools/work-next.js +5 -5
- package/dist/tools/work-sessions.js +15 -15
- package/package.json +1 -1
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { OrbotoClient } from '../orboto-client.js';
|
|
3
|
+
import { makeRuleProposalAcceptHandler, makeRuleProposalRejectHandler, makeRuleProposeHandler, makeRuleProposalsHandler } from './rule-proposals.js';
|
|
4
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
5
|
+
function stub(responses) {
|
|
6
|
+
const calls = [];
|
|
7
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
8
|
+
calls.push({ url: url.toString(), method: init?.method ?? 'GET', body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined });
|
|
9
|
+
const r = responses.shift();
|
|
10
|
+
if (!r)
|
|
11
|
+
throw new Error('unexpected extra fetch');
|
|
12
|
+
const status = r.status ?? 200;
|
|
13
|
+
return { ok: status < 400, status, statusText: 'x', json: async () => r.json, text: async () => JSON.stringify(r.json) };
|
|
14
|
+
});
|
|
15
|
+
return calls;
|
|
16
|
+
}
|
|
17
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
18
|
+
const ROW = {
|
|
19
|
+
id: '11111111-1111-1111-1111-111111111111', scope: 'project', projectKey: 'ORB', title: 'Run the gates one by one', status: 'pending',
|
|
20
|
+
duplicates: [{ kind: 'rule', id: '22222222-2222-2222-2222-222222222222', title: 'Gates run separately', exact: false }],
|
|
21
|
+
proposedByName: 'Claude Agent', decisionReason: null, canDecide: false,
|
|
22
|
+
};
|
|
23
|
+
describe('rule proposal MCP tools (ORB-2579)', () => {
|
|
24
|
+
it('propose posts the proposal with the workspace default and names the text matches', async () => {
|
|
25
|
+
const calls = stub([{ status: 201, json: ROW }]);
|
|
26
|
+
const res = await makeRuleProposeHandler(client)({ title: ROW.title, body: 'Rule: one gate per call.', projectKey: 'ORB', scope: 'project', reason: 'a chained call ran one gate' });
|
|
27
|
+
expect(calls[0]).toMatchObject({ method: 'POST', url: 'https://orboto.example.com/agent-instructions/proposals' });
|
|
28
|
+
expect(calls[0].body).toEqual({ title: ROW.title, body: 'Rule: one gate per call.', projectKey: 'ORB', scope: 'project', reason: 'a chained call ran one gate' });
|
|
29
|
+
const text = res.content[0].text;
|
|
30
|
+
expect(text).toContain('pending review by a person with rule rights');
|
|
31
|
+
expect(text).toContain('text match: rule "Gates run separately"');
|
|
32
|
+
expect(text).not.toMatch(/%/);
|
|
33
|
+
const calls2 = stub([{ status: 201, json: { ...ROW, scope: 'workspace', projectKey: null, duplicates: [] } }]);
|
|
34
|
+
await makeRuleProposeHandler(client)({ title: 'x', body: 'y' });
|
|
35
|
+
expect(calls2[0].body).toEqual({ title: 'x', body: 'y', scope: 'workspace' });
|
|
36
|
+
});
|
|
37
|
+
it('propose turns the hard-cap refusal into a blocked result', async () => {
|
|
38
|
+
stub([{ status: 422, json: { error: 'too long', errorKey: 'errors.agent_content.oversize', sizeWarning: { chars: 900, limit: 800, hint: 'shorten it' } } }]);
|
|
39
|
+
const res = await makeRuleProposeHandler(client)({ title: 'x', body: 'y'.repeat(900) });
|
|
40
|
+
expect(res.isError).toBe(true);
|
|
41
|
+
expect(res.content[0].text).toContain('Rule proposal blocked');
|
|
42
|
+
});
|
|
43
|
+
it('list sends status, mine and cursor and marks what the caller can decide', async () => {
|
|
44
|
+
const calls = stub([{ json: { items: [{ ...ROW, canDecide: true }], nextCursor: 'abc' } }]);
|
|
45
|
+
const res = await makeRuleProposalsHandler(client)({ status: 'all', mine: true, cursor: 'c1' });
|
|
46
|
+
expect(calls[0].url).toBe('https://orboto.example.com/agent-instructions/proposals?status=all&mine=true&cursor=c1');
|
|
47
|
+
const text = res.content[0].text;
|
|
48
|
+
expect(text).toContain('[pending] Run the gates one by one (project ORB');
|
|
49
|
+
expect(text).toContain('[you can decide]');
|
|
50
|
+
expect(text).toContain('More: cursor abc');
|
|
51
|
+
});
|
|
52
|
+
it('list defaults to the pending queue and says when it is empty', async () => {
|
|
53
|
+
const calls = stub([{ json: { items: [], nextCursor: null } }]);
|
|
54
|
+
const res = await makeRuleProposalsHandler(client)();
|
|
55
|
+
expect(calls[0].url).toBe('https://orboto.example.com/agent-instructions/proposals?status=pending');
|
|
56
|
+
expect(res.content[0].text).toBe('No rule proposals.');
|
|
57
|
+
});
|
|
58
|
+
it('list forwards the scope, project, customer and limit filters', async () => {
|
|
59
|
+
const calls = stub([{ json: { items: [], nextCursor: null } }]);
|
|
60
|
+
await makeRuleProposalsHandler(client)({ scope: 'project', projectId: '44444444-4444-4444-8444-444444444444', customerId: '55555555-5555-4555-8555-555555555555', limit: 10 });
|
|
61
|
+
expect(calls[0].url).toBe('https://orboto.example.com/agent-instructions/proposals?status=pending&scope=project&projectId=44444444-4444-4444-8444-444444444444&customerId=55555555-5555-4555-8555-555555555555&limit=10');
|
|
62
|
+
});
|
|
63
|
+
it('accept posts the edits and names the new block', async () => {
|
|
64
|
+
const calls = stub([{ json: { proposal: { ...ROW, status: 'accepted', acceptedEdited: true }, block: { id: 'b1' }, proposerNotified: true } }]);
|
|
65
|
+
const res = await makeRuleProposalAcceptHandler(client)({ proposalId: ROW.id, title: 'Short', mustRead: true, note: 'tightened' });
|
|
66
|
+
expect(calls[0]).toMatchObject({ method: 'POST', url: `https://orboto.example.com/agent-instructions/proposals/${ROW.id}/accept`, body: { title: 'Short', mustRead: true, note: 'tightened' } });
|
|
67
|
+
expect(res.content[0].text).toBe(`Accepted rule proposal "${ROW.title}" as rule block b1 (edited); the proposer got the decision as agent mail.`);
|
|
68
|
+
});
|
|
69
|
+
it('accept surfaces the person-only refusal and the hard-cap block', async () => {
|
|
70
|
+
stub([{ status: 403, json: { error: 'A person with rule rights decides on a rule proposal', errorKey: 'errors.agent_rules.review_needs_person' } }]);
|
|
71
|
+
await expect(makeRuleProposalAcceptHandler(client)({ proposalId: ROW.id })).rejects.toThrow(/403/);
|
|
72
|
+
stub([{ status: 422, json: { error: 'too long', sizeWarning: { chars: 900, limit: 800, hint: 'shorten it' } } }]);
|
|
73
|
+
const blocked = await makeRuleProposalAcceptHandler(client)({ proposalId: ROW.id, body: 'x'.repeat(900) });
|
|
74
|
+
expect(blocked.isError).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
it('reject posts the reason', async () => {
|
|
77
|
+
const calls = stub([{ json: { proposal: { ...ROW, status: 'rejected', decisionReason: 'covered' }, block: null, proposerNotified: false } }]);
|
|
78
|
+
const res = await makeRuleProposalRejectHandler(client)({ proposalId: ROW.id, reason: 'covered' });
|
|
79
|
+
expect(calls[0]).toMatchObject({ method: 'POST', url: `https://orboto.example.com/agent-instructions/proposals/${ROW.id}/reject`, body: { reason: 'covered' } });
|
|
80
|
+
expect(res.content[0].text).toBe(`Rejected rule proposal "${ROW.title}"; no agent mail was sent.`);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -57,7 +57,7 @@ it('search, search-docs, ask-docs and check-similar print the degraded line on s
|
|
|
57
57
|
child.on('close', (code) => code === 0 ? ok({ out, err }) : no(new Error(`Wrapper exit ${code}: ${err}`)));
|
|
58
58
|
child.stdin.end('');
|
|
59
59
|
});
|
|
60
|
-
for (const args of [['search', 'frobnicator'], ['search-docs', 'frobnicator'], ['ask-docs', 'how to restart'], ['check-similar', 'ORB', 'Frobnicator jams']]) {
|
|
60
|
+
for (const args of [['search', 'frobnicator'], ['search-docs', 'frobnicator'], ['ask-docs', 'how to restart'], ['check-similar', 'ORB', 'Frobnicator jams'], ['ticket', 'check-similar', 'ORB', 'Frobnicator jams']]) {
|
|
61
61
|
const { out, err } = await run(args);
|
|
62
62
|
expect(err, args.join(' ')).toContain(LINE);
|
|
63
63
|
expect(out, args.join(' ')).not.toContain(LINE);
|
|
@@ -9,29 +9,31 @@
|
|
|
9
9
|
* @see ORB-1605, ORB-1607, ORB-1818
|
|
10
10
|
*/
|
|
11
11
|
import { z } from 'zod';
|
|
12
|
-
import { resolveTicketByKey, applyAgentProfile, mcpInstanceToken, localSessionHost } from './shared.js';
|
|
12
|
+
import { resolveTicketByKey, applyAgentProfile, mcpInstanceToken, localSessionHost, presenceRefusal, exitedPredecessor } from './shared.js';
|
|
13
|
+
import { machineId, shortHost } from '../session-origin.js';
|
|
13
14
|
import { AgentSessionScopeSchema } from './agent-session-scope.js';
|
|
14
15
|
import { PROCESS_OWNER, PROTECT_TEXT_META, storePayload } from '../response-budget.js';
|
|
15
16
|
import { loadRequiredRules } from '../required-rules.js';
|
|
16
17
|
import { GIT_HEALTH_REASON_TEXT } from './git-health-reasons.js';
|
|
17
18
|
import { channelStartLines } from '../inbox-channel.js';
|
|
18
19
|
import { rememberDeclaredScope } from '../session-scope-memory.js';
|
|
19
|
-
import { declareEnvProjectScope, envProjectScope } from '../project-scope.js';
|
|
20
|
+
import { declareEnvProjectScope, envProjectScope, supervisorScopePending } from '../project-scope.js';
|
|
20
21
|
import { agentHeadText } from '../agent-head.js';
|
|
21
22
|
import { agentContextKey } from '../agent-context.js';
|
|
22
23
|
export const sessionStartToolConfig = {
|
|
23
24
|
title: 'Load the rules you must follow + re-orient',
|
|
24
25
|
description: 'THE canonical way to LOAD the binding workspace rules you must follow as an agent. Run it FIRST in a session and AFTER every context compaction. Returns the rules in full (above the full-text bound: one title per rule plus every must-read rule in full, the rest via `rulesOnly: true`); a compact "unchanged" ack only on a repeat inside the same agent context. Also returns your in-progress tickets (LANDED, IDLE = a linked commit but no move for days: finished work never handed to review), your timer and unhealthy git connections. Not orboto_list_agent_instructions - that one edits rule blocks for admins. Registers this session (a heartbeat on its instance row), otherwise read-only. '
|
|
25
|
-
+ '**Parameters.** `rulesOnly: true` returns ONLY the complete rules text, never truncated. `projectId` adds that project\'s rules. `ticketKey` ("ACME-42") bundles that ticket\'s primer, detail, dependencies, checklists, git health and other agent sessions. `forceRules: true` returns the full text inline even above the bound or after an ack - use it whenever the rules are NOT in your context. `agentKind` (coding, orchestrator, reviewer, runner) and `modelTier` (frontier, standard, small) target rule blocks and per-tier text. `scope` { projectKeys, ticketKeys, role } declares this session\'s responsibility; session.ref is what peers pass as toSessionRef (ORB-2136). '
|
|
26
|
+
+ '**Parameters.** `rulesOnly: true` returns ONLY the complete rules text, never truncated. `projectId` adds that project\'s rules. `ticketKey` ("ACME-42") bundles that ticket\'s primer, detail, dependencies, checklists, git health and other agent sessions. `forceRules: true` returns the full text inline even above the bound or after an ack - use it whenever the rules are NOT in your context. `agentKind` (coding, orchestrator, reviewer, runner) and `modelTier` (frontier, standard, small) target rule blocks and per-tier text. `scope` { projectKeys, ticketKeys, role } declares this session\'s responsibility; session.ref is what peers pass as toSessionRef (ORB-2136). `previous` (adopt | end) answers a second-start refusal (ORB-2553). '
|
|
26
27
|
+ 'Also carries the Knowledge block (ORB-2225: the wiki index of your declared projects and the Operations wiki; ask with orboto_knowledge_ask before a person, file durable facts with orboto_knowledge_add) and the Responsibilities block (ORB-2542: who owns the topics of your projects, orboto_who before agent mail). Right after the rules come Where knowledge lives (ORB-2575: rules bind behaviour, project facts hold fixed decisions, the wiki the long tail; a decision goes to a fact proposal, orboto_primer_fact_add with observed: true) and the Project facts of your declared projects.',
|
|
27
28
|
inputSchema: z.object({
|
|
28
|
-
projectId: z.string().uuid().optional()
|
|
29
|
-
ticketKey: z.string().min(3).optional()
|
|
30
|
-
forceRules: z.boolean().optional()
|
|
31
|
-
rulesOnly: z.boolean().optional()
|
|
32
|
-
agentKind: z.string().min(1).max(32).optional()
|
|
33
|
-
modelTier: z.string().min(1).max(32).optional()
|
|
29
|
+
projectId: z.string().uuid().optional(),
|
|
30
|
+
ticketKey: z.string().min(3).optional(),
|
|
31
|
+
forceRules: z.boolean().optional(),
|
|
32
|
+
rulesOnly: z.boolean().optional(),
|
|
33
|
+
agentKind: z.string().min(1).max(32).optional(),
|
|
34
|
+
modelTier: z.string().min(1).max(32).optional(),
|
|
34
35
|
scope: AgentSessionScopeSchema.optional(),
|
|
36
|
+
previous: z.enum(['adopt', 'end']).optional(),
|
|
35
37
|
}).shape,
|
|
36
38
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
37
39
|
};
|
|
@@ -175,6 +177,12 @@ function knowledgeSummary(index, handle) {
|
|
|
175
177
|
};
|
|
176
178
|
}
|
|
177
179
|
/** ORB-2209 - a declared scope stays with the instance token; the agent declares it once per terminal. */
|
|
180
|
+
/** ORB-2553 - what the start did to the session it ran into. */
|
|
181
|
+
export function takeoverLine(t) {
|
|
182
|
+
if (t.action === 'ended')
|
|
183
|
+
return `Ended the previous session ${t.shortId} in this repository${t.releasedTickets.length ? `; its leases on ${t.releasedTickets.join(', ')} are released` : ''}.`;
|
|
184
|
+
return `Adopted session ${t.shortId}: ${t.movedMessages} unread message(s)${t.movedLeases.length ? ` and the leases on ${t.movedLeases.join(', ')}` : ''} moved here, it is ended.`;
|
|
185
|
+
}
|
|
178
186
|
export function identityLine(registration) {
|
|
179
187
|
const count = registration.reconnects ?? 0;
|
|
180
188
|
const kept = count > 0 ? ` It survived ${count} reconnect(s), the last at ${registration.lastReconnectAt ?? 'an unknown time'}.` : '';
|
|
@@ -223,15 +231,34 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
223
231
|
const toRegistration = (r) => (r && typeof r.sessionId === 'string' ? {
|
|
224
232
|
sessionId: r.sessionId, scope: r.scope ?? null, reconnects: r.reconnects, lastReconnectAt: r.lastReconnectAt,
|
|
225
233
|
wake: r.wake, scopeExpired: r.scopeExpired ?? null, scopeContinued: r.scopeContinued ?? null, notes: Array.isArray(r.notes) ? r.notes.filter((n) => typeof n === 'string') : [],
|
|
234
|
+
...(r.takeover ? { takeover: r.takeover } : {}),
|
|
226
235
|
} : null);
|
|
236
|
+
let refusal = null;
|
|
237
|
+
const refused = (err) => { refusal = presenceRefusal(err)?.message ?? refusal; return null; };
|
|
227
238
|
const host = localSessionHost(extra);
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
.
|
|
231
|
-
|
|
239
|
+
const fallbackScope = input.scope === undefined && !supervisorScopePending() ? envProjectScope() : null;
|
|
240
|
+
const startBody = {
|
|
241
|
+
start: true, ...(input.previous ? { previous: input.previous } : {}), ...(input.scope !== undefined ? { scope: input.scope } : {}),
|
|
242
|
+
...(fallbackScope ? { fallbackScope } : {}), ...(host ? { clientInfo: { host } } : {}),
|
|
243
|
+
};
|
|
244
|
+
const beat = (body) => client.post('/v1/agent/heartbeat', body, { instanceToken }).then(toRegistration);
|
|
245
|
+
let exitedLine = null;
|
|
246
|
+
let registration = await beat(startBody).catch(async (err) => {
|
|
247
|
+
const dead = exitedPredecessor(presenceRefusal(err), { machine: machineId(), host: shortHost() });
|
|
248
|
+
if (!dead)
|
|
249
|
+
return refused(err);
|
|
250
|
+
const again = await beat({ ...startBody, previous: 'end' }).catch(refused);
|
|
251
|
+
if (again)
|
|
252
|
+
exitedLine = dead.line;
|
|
253
|
+
return again;
|
|
254
|
+
});
|
|
255
|
+
if ((input.scope !== undefined || fallbackScope) && registration?.scope)
|
|
232
256
|
rememberDeclaredScope(instanceToken, registration.scope);
|
|
233
|
-
if (
|
|
234
|
-
|
|
257
|
+
if (fallbackScope && registration && !registration.scope) {
|
|
258
|
+
const declared = await declareEnvProjectScope(client, instanceToken);
|
|
259
|
+
if (declared?.refusal)
|
|
260
|
+
refusal = declared.refusal;
|
|
261
|
+
registration = toRegistration(declared) ?? (declared?.refusal ? null : registration);
|
|
235
262
|
}
|
|
236
263
|
const [me, rules, assigned, timer, inboxRaw, knowledge, owners, projectFacts] = await Promise.all([
|
|
237
264
|
client.get('/users/me').catch(() => null),
|
|
@@ -340,7 +367,14 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
340
367
|
: registration
|
|
341
368
|
? 'no scope declared - this session lists every account-addressed message; pass scope: { projectKeys, role } to narrow it'
|
|
342
369
|
: 'registration failed - only account mail is listed; retry orboto_session_start or check mcp:use';
|
|
343
|
-
|
|
370
|
+
if (refusal)
|
|
371
|
+
lines.push(`ref ${instanceToken}. Not registered: ${refusal}`, 'With MCP: pass scope: { role, projectKeys } and, for another live session in this repository, previous: "adopt" or "end".');
|
|
372
|
+
else
|
|
373
|
+
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}" }.`);
|
|
374
|
+
if (exitedLine)
|
|
375
|
+
lines.push(exitedLine);
|
|
376
|
+
else if (registration?.takeover)
|
|
377
|
+
lines.push(takeoverLine(registration.takeover));
|
|
344
378
|
const roleAddresses = registration?.scope?.role ? (registration.scope.projectKeys ?? []).map((k) => `${registration.scope.role}@${k}`) : [];
|
|
345
379
|
if (roleAddresses.length)
|
|
346
380
|
lines.push(`Address form: peers reach this session and every other ${registration.scope.role} of the project with orboto_agent_notify { to: "${roleAddresses[0]}" } (${roleAddresses.join(', ')}); address a role, never a session id copied from a log.`);
|
|
@@ -384,6 +418,8 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
384
418
|
reconnects: registration?.reconnects ?? 0, reconnectedAt: registration?.lastReconnectAt ?? null,
|
|
385
419
|
...(registration?.wake ? { wake: registration.wake } : {}), scopeExpired: registration?.scopeExpired ?? null,
|
|
386
420
|
scopeContinued: registration?.scopeContinued ?? null,
|
|
421
|
+
...(refusal ? { refused: refusal } : {}),
|
|
422
|
+
...(registration?.takeover ? { takeover: registration.takeover } : {}),
|
|
387
423
|
},
|
|
388
424
|
gitHealth: {
|
|
389
425
|
unhealthy: gitHealthWithConnections
|
|
@@ -18,8 +18,9 @@ import { makeResponseExpandHandler } from './response-expand.js';
|
|
|
18
18
|
import { applyResponseBudget, budgetFor, DEFAULT_BUDGET_CHARS } from '../response-budget.js';
|
|
19
19
|
import { hostname, tmpdir } from 'node:os';
|
|
20
20
|
import { localSessionHost } from './shared.js';
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
/** An `orboto claude` or `orboto connect` session exports its checkout project and role; the digest must not inherit them here. */
|
|
22
|
+
beforeEach(() => { vi.restoreAllMocks(); vi.stubEnv('ORBOTO_PROJECT_KEY', ''); vi.stubEnv('ORBOTO_SESSION_ROLE', ''); });
|
|
23
|
+
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); });
|
|
23
24
|
function stubByPath(map) {
|
|
24
25
|
const calls = [];
|
|
25
26
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
|
|
@@ -189,7 +190,7 @@ describe('orboto_session_start - rules-hash ack (ORB-1607, ORB-2576)', () => {
|
|
|
189
190
|
});
|
|
190
191
|
});
|
|
191
192
|
describe('orboto_session_start - knowledge layers and project facts (ORB-2575)', () => {
|
|
192
|
-
const FACTS_TEXT = '## Where knowledge lives\n- Rules: binding behaviour.\n\n## Project facts\n### ORB
|
|
193
|
+
const FACTS_TEXT = '## Where knowledge lives\n- Rules: binding behaviour.\n\n## Project facts\n### ORB: 1 of 3 facts\n- tech_stack/stack: Go and Postgres\n2 more: `orboto primer ORB`';
|
|
193
194
|
const facts = (text) => ({
|
|
194
195
|
source: 'session', unknown: [], text,
|
|
195
196
|
projects: [{ projectKey: 'ORB', projectId: 'proj-1', total: 3, facts: [{ category: 'tech_stack', key: 'stack', value: 'Go and Postgres', observed: false }] }],
|
|
@@ -646,7 +647,7 @@ describe('ORB-2136 - session scope and ref', () => {
|
|
|
646
647
|
const res = await makeSessionStartHandler(client)({ scope: { role: 'integrator', projectKeys: ['orb'] } }, { sessionId: 'abc123' });
|
|
647
648
|
const beat = seen.find((c) => c.path === '/v1/agent/heartbeat');
|
|
648
649
|
expect(beat?.headers['x-orboto-agent-session']).toBe('mcp-abc123');
|
|
649
|
-
expect(beat?.body).toEqual({ scope: { role: 'integrator', projectKeys: ['orb'] } });
|
|
650
|
+
expect(beat?.body).toEqual({ start: true, scope: { role: 'integrator', projectKeys: ['orb'] } });
|
|
650
651
|
expect(seen.find((c) => c.path.startsWith('/v1/agent/messages'))?.headers['x-orboto-agent-session']).toBe('mcp-abc123');
|
|
651
652
|
const text = res.content[0].text;
|
|
652
653
|
expect(text).toContain('ref mcp-abc123, instance cbb52195. role integrator; projects ORB');
|
|
@@ -682,11 +683,11 @@ describe('ORB-2136 - session scope and ref', () => {
|
|
|
682
683
|
return { ok: true, status: 200, statusText: 'OK', json: async () => body, text: async () => '' };
|
|
683
684
|
});
|
|
684
685
|
await makeSessionStartHandler(client)({});
|
|
685
|
-
expect(seen.find((c) => c.path === '/v1/agent/heartbeat')?.body).toEqual({ clientInfo: { host: hostname().trim().toLowerCase() } });
|
|
686
|
+
expect(seen.find((c) => c.path === '/v1/agent/heartbeat')?.body).toEqual({ start: true, clientInfo: { host: hostname().trim().toLowerCase() } });
|
|
686
687
|
expect(localSessionHost({ sessionId: 'http-1' }, 'server')).toBeUndefined();
|
|
687
688
|
expect(localSessionHost(undefined, ' Mac-A ')).toBe('mac-a');
|
|
688
689
|
});
|
|
689
|
-
it('without a scope the heartbeat body
|
|
690
|
+
it('without a scope the heartbeat body carries only the start and the digest says no scope is declared', async () => {
|
|
690
691
|
const seen = [];
|
|
691
692
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
692
693
|
const u = new URL(url.toString());
|
|
@@ -697,7 +698,7 @@ describe('ORB-2136 - session scope and ref', () => {
|
|
|
697
698
|
return { ok: true, status: 200, statusText: 'OK', json: async () => body, text: async () => '' };
|
|
698
699
|
});
|
|
699
700
|
const res = await makeSessionStartHandler(client)({}, { sessionId: 'abc123' });
|
|
700
|
-
expect(seen.find((c) => c.path === '/v1/agent/heartbeat')?.body).toEqual({});
|
|
701
|
+
expect(seen.find((c) => c.path === '/v1/agent/heartbeat')?.body).toEqual({ start: true });
|
|
701
702
|
expect(res.content[0].text).toContain('no scope declared');
|
|
702
703
|
});
|
|
703
704
|
it('ORB-2148: a stdio proxy with the channel on hands over the exact start commands; other servers stay silent', async () => {
|
package/dist/tools/shared.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
1
2
|
import { OrbotoApiError } from '../orboto-client.js';
|
|
2
3
|
/** ORB-2543 - the line every search-shaped tool prints when the API flags `semanticDegraded`. */
|
|
3
4
|
export const SEMANTIC_DEGRADED_LINE = 'semantic search unavailable, showing full-text results';
|
|
@@ -178,6 +179,54 @@ export function applyAgentProfile(params, explicit) {
|
|
|
178
179
|
if (tier)
|
|
179
180
|
params.set('modelTier', tier);
|
|
180
181
|
}
|
|
182
|
+
export function presenceRefusal(err) {
|
|
183
|
+
if (!(err instanceof OrbotoApiError) || (err.status !== 409 && err.status !== 422))
|
|
184
|
+
return null;
|
|
185
|
+
let parsed = {};
|
|
186
|
+
try {
|
|
187
|
+
parsed = JSON.parse(err.body);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
if (parsed.errorKey !== 'errors.agents.scope_required' && parsed.errorKey !== 'errors.agents.session_exists')
|
|
193
|
+
return null;
|
|
194
|
+
const notes = Array.isArray(parsed.errorParams?.notes) ? parsed.errorParams.notes.filter((n) => typeof n === 'string') : [];
|
|
195
|
+
return { key: parsed.errorKey, message: [parsed.error ?? parsed.errorKey, ...notes].join('\n'), params: parsed.errorParams ?? {} };
|
|
196
|
+
}
|
|
197
|
+
/** True while pid belongs to a running process: signal 0 answers ESRCH for none, and an exited zombie on Linux counts as gone. */
|
|
198
|
+
export function processAlive(pid) {
|
|
199
|
+
try {
|
|
200
|
+
process.kill(pid, 0);
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
if (err.code === 'ESRCH')
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
208
|
+
return stat[stat.lastIndexOf(')') + 2] !== 'Z';
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** Every identifier both sides name must match, machine id and host alike, so containers of one image or cloned VMs never count as one machine. */
|
|
215
|
+
export function sameMachine(theirMachine, ourMachine, theirHost, ourHost) {
|
|
216
|
+
const machines = Boolean(theirMachine && ourMachine);
|
|
217
|
+
const hosts = Boolean(theirHost && ourHost);
|
|
218
|
+
return (machines || hosts) && (!machines || theirMachine === ourMachine) && (!hosts || theirHost === ourHost);
|
|
219
|
+
}
|
|
220
|
+
/** ORB-2553 - the session a second-start refusal names when it runs on this machine under a pid that no longer runs; null otherwise. */
|
|
221
|
+
export function exitedPredecessor(refusal, local, alive = processAlive) {
|
|
222
|
+
if (refusal?.key !== 'errors.agents.session_exists')
|
|
223
|
+
return null;
|
|
224
|
+
const p = refusal.params;
|
|
225
|
+
const same = sameMachine(typeof p.machineId === 'string' ? p.machineId : null, local.machine, typeof p.host === 'string' ? p.host : null, local.host);
|
|
226
|
+
if (typeof p.pid !== 'number' || !Number.isInteger(p.pid) || p.pid <= 1 || !same || typeof p.shortId !== 'string' || alive(p.pid))
|
|
227
|
+
return null;
|
|
228
|
+
return { shortId: p.shortId, pid: p.pid, line: `previous session ${p.shortId} (pid ${p.pid}) has exited, ended it` };
|
|
229
|
+
}
|
|
181
230
|
export function sizeBlockResult(err, verb) {
|
|
182
231
|
if (!(err instanceof OrbotoApiError) || err.status !== 422)
|
|
183
232
|
return null;
|
|
@@ -620,7 +620,7 @@ export const setMilestoneToolConfig = {
|
|
|
620
620
|
description: 'Move a ticket onto a different milestone (or off all milestones with milestone=null/undefined). Resolves the milestone by key (e.g. "ORB-M3"), name, OR UUID within the ticket\'s project (including closed/archived). If two milestones share a name, pass the key or UUID - an ambiguous name is rejected, not silently guessed.',
|
|
621
621
|
inputSchema: z.object({
|
|
622
622
|
ticketKey: z.string().min(3),
|
|
623
|
-
milestone: z.string().nullable().optional().describe('Milestone key (
|
|
623
|
+
milestone: z.string().nullable().optional().describe('Milestone key ("ORB-M3"), name or UUID (key when the name is ambiguous); null removes it.'),
|
|
624
624
|
}).shape,
|
|
625
625
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
626
626
|
};
|
|
@@ -719,7 +719,7 @@ export function makeRemoveTicketDependencyHandler(client) {
|
|
|
719
719
|
}
|
|
720
720
|
export const listTicketDependenciesToolConfig = {
|
|
721
721
|
title: 'List a ticket\'s dependencies',
|
|
722
|
-
description: '
|
|
722
|
+
description: 'Both directions of a ticket\'s dependencies: `blockedBy` (finish first) and `blocks` (waiting on it). ORB-1614: an edge to a ticket you cannot read comes back as an opaque stub (no title/key/status - just whether it is still open); `external` marks another project.',
|
|
723
723
|
inputSchema: z.object({
|
|
724
724
|
ticketKey: z.string().min(3),
|
|
725
725
|
}).shape,
|
package/dist/tools/wiki.js
CHANGED
|
@@ -161,7 +161,7 @@ export const wikiSaveAnswerToolConfig = {
|
|
|
161
161
|
answer: z.string().min(1).max(50_000),
|
|
162
162
|
title: z.string().max(200).optional().describe('Defaults to the question.'),
|
|
163
163
|
parentDocId: z.string().uuid().optional(),
|
|
164
|
-
citations: z.array(z.object({ docId: z.string().min(1).describe('Doc UUID or
|
|
164
|
+
citations: z.array(z.object({ docId: z.string().min(1).describe('Doc UUID or key (ORB-D12, DOC-5).'), title: z.string() })).max(50).optional().describe('From orboto_wiki_ask.'),
|
|
165
165
|
replace: z.boolean().optional(),
|
|
166
166
|
}).shape,
|
|
167
167
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
@@ -177,7 +177,7 @@ export const wikiFlagStaleToolConfig = {
|
|
|
177
177
|
title: 'Flag (or unflag) a wiki page as possibly outdated',
|
|
178
178
|
description: 'Set or clear the "may be outdated" flag on a page. The flag surfaces passively as a pill in the UI; it does not change the content. Pass stale=false to clear. Wraps POST /docs/:id/flag-stale.',
|
|
179
179
|
inputSchema: z.object({
|
|
180
|
-
docId: z.string().min(1).describe('Doc UUID or
|
|
180
|
+
docId: z.string().min(1).describe('Doc UUID or key (ORB-D12, DOC-5).'),
|
|
181
181
|
stale: z.boolean().optional().describe('true to flag (default), false to clear.'),
|
|
182
182
|
}).shape,
|
|
183
183
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
package/dist/tools/work-next.js
CHANGED
|
@@ -27,21 +27,21 @@ export const workNextToolConfig = {
|
|
|
27
27
|
inputSchema: z.object({
|
|
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
|
-
.describe('
|
|
30
|
+
.describe('Routing tag: `agent:<tag>` tickets first, foreign `agent:*` last but eligible.'),
|
|
31
31
|
role: z.enum(['implementation', 'review', 'preflight', 'integration', 'spec']).optional()
|
|
32
32
|
.describe('Default `implementation`; reserves only a ticket whose (ticket, role) lease is free.'),
|
|
33
33
|
leaseSeconds: z.number().int().min(60).max(86_400).optional()
|
|
34
|
-
.describe('
|
|
34
|
+
.describe('Lease without renewal, default 900 (15 min).'),
|
|
35
35
|
startTimer: z.boolean().optional()
|
|
36
|
-
.describe('
|
|
36
|
+
.describe('Default true for implementation, false otherwise.'),
|
|
37
37
|
agentSessionToken: z.string().optional()
|
|
38
|
-
.describe('
|
|
38
|
+
.describe('Agent instance token; default this connection\'s.'),
|
|
39
39
|
resourceClaims: z.array(z.object(ResourceClaimShape)).max(50).optional()
|
|
40
40
|
.describe('ORB-1610 - claims to acquire with the reservation; a ticket under a conflicting granted write claim is skipped.'),
|
|
41
41
|
onConflict: z.enum(['reject', 'queue']).optional()
|
|
42
42
|
.describe('Only matters when `resourceClaims` is set on the WINNING candidate. Default `reject`.'),
|
|
43
43
|
peek: z.boolean().optional()
|
|
44
|
-
.describe('
|
|
44
|
+
.describe('Look without reserving (no lease, timer or quota); returns `candidate` or the empty reason.'),
|
|
45
45
|
}).shape,
|
|
46
46
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
47
47
|
};
|
|
@@ -49,19 +49,19 @@ export const workStartToolConfig = {
|
|
|
49
49
|
inputSchema: z.object({
|
|
50
50
|
ticketKey: z.string().min(3).describe('Ticket key like "ACME-42".'),
|
|
51
51
|
role: z.enum(['implementation', 'review', 'preflight', 'integration', 'spec']).optional()
|
|
52
|
-
.describe('Default implementation;
|
|
52
|
+
.describe('Default implementation; others attach without reassignment; spec needs the bot spec tag.'),
|
|
53
53
|
leaseSeconds: z.number().int().min(60).max(86_400).optional()
|
|
54
|
-
.describe('
|
|
54
|
+
.describe('Lease without renewal, default 900 (15 min).'),
|
|
55
55
|
override: z.boolean().optional().describe("Bypass the spec gate; reason required. Never epics."),
|
|
56
56
|
reason: z.string().trim().min(1).max(2000).optional(),
|
|
57
57
|
takeover: z.boolean().optional()
|
|
58
|
-
.describe('Displace the
|
|
58
|
+
.describe('Displace the lease holder; their session is cancelled, their time booked.'),
|
|
59
59
|
startTimer: z.boolean().optional()
|
|
60
|
-
.describe('
|
|
60
|
+
.describe('Default true for implementation, false otherwise.'),
|
|
61
61
|
agentSessionToken: z.string().optional()
|
|
62
|
-
.describe('
|
|
62
|
+
.describe('Agent instance token; default this connection\'s.'),
|
|
63
63
|
resourceClaims: z.array(z.object(ResourceClaimShape)).max(50).optional()
|
|
64
|
-
.describe('
|
|
64
|
+
.describe('Resource claims with the lease, as in orboto_work_session_start.'),
|
|
65
65
|
onConflict: z.enum(['reject', 'queue']).optional()
|
|
66
66
|
.describe('Only matters when `resourceClaims` is set. Default `reject`.'),
|
|
67
67
|
}).shape,
|
|
@@ -216,19 +216,19 @@ export const workSessionStartToolConfig = {
|
|
|
216
216
|
inputSchema: z.object({
|
|
217
217
|
ticketKey: z.string().min(3).describe('Ticket key like "ACME-42".'),
|
|
218
218
|
role: z.enum(['implementation', 'review', 'preflight', 'integration', 'spec']).optional()
|
|
219
|
-
.describe('Default implementation;
|
|
219
|
+
.describe('Default implementation; others attach without reassignment; spec needs the bot spec tag.'),
|
|
220
220
|
leaseSeconds: z.number().int().min(60).max(86_400).optional()
|
|
221
|
-
.describe('
|
|
221
|
+
.describe('Lease without renewal, default 900 (15 min).'),
|
|
222
222
|
override: z.boolean().optional().describe("Bypass the spec gate; reason required. Never epics."),
|
|
223
223
|
reason: z.string().trim().min(1).max(2000).optional(),
|
|
224
224
|
takeover: z.boolean().optional()
|
|
225
|
-
.describe('Displace the
|
|
225
|
+
.describe('Displace the lease holder; their session is cancelled, their time booked.'),
|
|
226
226
|
startTimer: z.boolean().optional()
|
|
227
|
-
.describe('
|
|
227
|
+
.describe('Default true for implementation, false otherwise.'),
|
|
228
228
|
agentSessionToken: z.string().optional()
|
|
229
|
-
.describe('
|
|
229
|
+
.describe('Agent instance token; default this connection\'s.'),
|
|
230
230
|
resourceClaims: z.array(z.object(ResourceClaimShape)).max(50).optional()
|
|
231
|
-
.describe('Atomic
|
|
231
|
+
.describe('Atomic claims: path = repo-relative glob, named = exact id; overlapping writes conflict, reads never.'),
|
|
232
232
|
onConflict: z.enum(['reject', 'queue']).optional()
|
|
233
233
|
.describe('reject rolls back a new session on claim conflict; queue keeps it with waiting claims, promoted when conflicts clear.'),
|
|
234
234
|
}).shape,
|
|
@@ -318,7 +318,7 @@ export const workSessionFinishToolConfig = {
|
|
|
318
318
|
inputSchema: z.object({
|
|
319
319
|
sessionId: z.string().uuid().describe('The id returned by orboto_work_session_start.'),
|
|
320
320
|
outcome: z.enum(['finished', 'cancelled']).optional()
|
|
321
|
-
.describe('`finished` (default)
|
|
321
|
+
.describe('`finished` (default) or `cancelled` (abandoned; time is still booked).'),
|
|
322
322
|
commitSha: z.string().optional().describe('The commit this session produced, when it produced one.'),
|
|
323
323
|
verification: z.object({
|
|
324
324
|
reviewOfSessionId: z.string().uuid().optional().describe('Exact independent implementation work session this review examined.'),
|
|
@@ -460,7 +460,7 @@ export const workSessionClaimsAddToolConfig = {
|
|
|
460
460
|
inputSchema: z.object({
|
|
461
461
|
sessionId: z.string().uuid().describe('The id returned by orboto_work_session_start.'),
|
|
462
462
|
claims: z.array(z.object(ResourceClaimShape)).min(1).max(50)
|
|
463
|
-
.describe('Claims to add
|
|
463
|
+
.describe('Claims to add: path = repo-relative glob, named = exclusive exact id.'),
|
|
464
464
|
onConflict: z.enum(['reject', 'queue']).optional().describe('Default `reject`.'),
|
|
465
465
|
}).shape,
|
|
466
466
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
@@ -501,7 +501,7 @@ export function makeWorkSessionClaimsAddHandler(client) {
|
|
|
501
501
|
}
|
|
502
502
|
export const workSessionClaimsReleaseToolConfig = {
|
|
503
503
|
title: 'Release resource claims from a live work session',
|
|
504
|
-
description: '
|
|
504
|
+
description: 'Drop resource claims (or all) from your session without finishing it or touching lease and timer. Releasing a granted write claim immediately runs a grant pass, so the earliest queued waiter for that resource is promoted as part of this call - useful once you know you are done touching a subtree but are not done with the ticket. Omit `claims` to release everything the session holds.',
|
|
505
505
|
inputSchema: z.object({
|
|
506
506
|
sessionId: z.string().uuid().describe('The id returned by orboto_work_session_start.'),
|
|
507
507
|
claims: z.array(z.object({ kind: z.enum(['path', 'named']), value: z.string().min(1).max(500) })).optional()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orboto/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.228.1",
|
|
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",
|