@orboto/mcp 0.222.2 → 0.224.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/agent-context.js +49 -0
- package/dist/agent-context.test.js +45 -0
- package/dist/channel-start.js +52 -0
- package/dist/channel-start.test.js +83 -0
- package/dist/claude-context.js +8 -3
- package/dist/inbox-channel-acknowledged.test.js +19 -0
- package/dist/inbox-channel.js +11 -6
- package/dist/index.js +12 -2
- package/dist/orboto-client.js +14 -2
- package/dist/session-origin.js +17 -2
- package/dist/session-origin.test.js +12 -1
- package/dist/tail-labels.js +2 -0
- package/dist/tools/agent-coordination.js +12 -7
- package/dist/tools/agent-instructions.js +8 -5
- package/dist/tools/agent-messages.js +7 -1
- package/dist/tools/agent-presence-schema.js +11 -0
- package/dist/tools/agent-wakes.js +3 -1
- package/dist/tools/agent-wakes.test.js +8 -0
- package/dist/tools/agent-who.js +3 -1
- package/dist/tools/primer-facts.js +1 -1
- package/dist/tools/project-readiness-schema.js +4 -1
- package/dist/tools/query.js +49 -6
- package/dist/tools/query.test.js +69 -0
- package/dist/tools/session-start.js +62 -19
- package/dist/tools/session-start.test.js +109 -12
- package/dist/tools/update-project.js +3 -1
- package/dist/tools/update-project.test.js +10 -0
- package/dist/version-guard.test.js +3 -1
- package/package.json +1 -1
|
@@ -11,10 +11,12 @@ import { OrbotoClient } from '../orboto-client.js';
|
|
|
11
11
|
import { makeSessionStartHandler } from './session-start.js';
|
|
12
12
|
import { _forgetDeclaredScopes, rememberedScope } from '../session-scope-memory.js';
|
|
13
13
|
import { CHANNEL_START_COMMANDS, CHANNEL_START_COMMANDS_WITHOUT_CLI } from '../inbox-channel.js';
|
|
14
|
-
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { ORBOTO_HOOK_EVENTS, agentContextKey } from '../agent-context.js';
|
|
15
17
|
import { makeResponseExpandHandler } from './response-expand.js';
|
|
16
18
|
import { applyResponseBudget, budgetFor, DEFAULT_BUDGET_CHARS } from '../response-budget.js';
|
|
17
|
-
import { hostname } from 'node:os';
|
|
19
|
+
import { hostname, tmpdir } from 'node:os';
|
|
18
20
|
import { localSessionHost } from './shared.js';
|
|
19
21
|
beforeEach(() => { vi.restoreAllMocks(); });
|
|
20
22
|
afterEach(() => { vi.restoreAllMocks(); });
|
|
@@ -120,8 +122,8 @@ describe('orboto_session_start (ORB-1093)', () => {
|
|
|
120
122
|
expect(text).not.toContain('Git connection health - WARNING');
|
|
121
123
|
});
|
|
122
124
|
});
|
|
123
|
-
describe('orboto_session_start - rules-hash ack (ORB-1607)', () => {
|
|
124
|
-
it('sends no knownRulesHash on the first call, then passes the received hash back
|
|
125
|
+
describe('orboto_session_start - rules-hash ack (ORB-1607, ORB-2576)', () => {
|
|
126
|
+
it('sends no knownRulesHash on the first call, then passes the received hash back inside the same agent context', async () => {
|
|
125
127
|
const calls = [];
|
|
126
128
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
|
|
127
129
|
const u = new URL(url.toString());
|
|
@@ -141,7 +143,8 @@ describe('orboto_session_start - rules-hash ack (ORB-1607)', () => {
|
|
|
141
143
|
const key = Object.keys(fallback).find((k) => (u.pathname + u.search).startsWith(k));
|
|
142
144
|
return { ok: true, status: 200, statusText: 'OK', json: async () => (key ? fallback[key] : {}), text: async () => '' };
|
|
143
145
|
});
|
|
144
|
-
|
|
146
|
+
let context = 'claude-code sess-a 2026-10-18T10:00:00Z';
|
|
147
|
+
const handler = makeSessionStartHandler(client, { agentContext: () => context });
|
|
145
148
|
const first = await handler();
|
|
146
149
|
const firstCall = calls.find((c) => c.startsWith('/agent-instructions'));
|
|
147
150
|
expect(firstCall).toBe('/agent-instructions');
|
|
@@ -155,13 +158,23 @@ describe('orboto_session_start - rules-hash ack (ORB-1607)', () => {
|
|
|
155
158
|
const secondCall = calls.find((c) => c.startsWith('/agent-instructions'));
|
|
156
159
|
expect(secondCall).toBe('/agent-instructions?knownRulesHash=abc123def456');
|
|
157
160
|
const secondText = second.content[0].text;
|
|
158
|
-
expect(secondText).toContain('Unchanged since this
|
|
161
|
+
expect(secondText).toContain('Unchanged since this agent context received them in full');
|
|
159
162
|
expect(secondText).toContain('forceRules=true');
|
|
160
163
|
expect(secondText).not.toContain('keep following what you already loaded');
|
|
161
164
|
expect(secondText).not.toContain('claim -> commit -> close');
|
|
162
165
|
const secondStructured = second.structuredContent;
|
|
163
166
|
expect(secondStructured.rulesHash).toBe('abc123def456');
|
|
164
167
|
expect(secondStructured.rulesUnchanged).toBe(true);
|
|
168
|
+
calls.length = 0;
|
|
169
|
+
context = 'claude-code sess-a 2026-10-18T11:00:00Z';
|
|
170
|
+
const afterCompaction = await handler();
|
|
171
|
+
expect(calls.find((c) => c.startsWith('/agent-instructions'))).toBe('/agent-instructions');
|
|
172
|
+
expect(afterCompaction.content[0].text).toContain('claim -> commit -> close');
|
|
173
|
+
calls.length = 0;
|
|
174
|
+
context = '';
|
|
175
|
+
await handler();
|
|
176
|
+
await handler();
|
|
177
|
+
expect(calls.filter((c) => c.startsWith('/agent-instructions'))).toEqual(['/agent-instructions', '/agent-instructions']);
|
|
165
178
|
});
|
|
166
179
|
it('a fresh handler (new connection) starts with no cached hash again', async () => {
|
|
167
180
|
const calls = stubByPath({
|
|
@@ -175,6 +188,46 @@ describe('orboto_session_start - rules-hash ack (ORB-1607)', () => {
|
|
|
175
188
|
expect(call).toBe('/agent-instructions');
|
|
176
189
|
});
|
|
177
190
|
});
|
|
191
|
+
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 (project id proj-1): 1 of 3 facts\n- tech_stack/stack: Go and Postgres\n2 more: `orboto primer ORB`';
|
|
193
|
+
const facts = (text) => ({
|
|
194
|
+
source: 'session', unknown: [], text,
|
|
195
|
+
projects: [{ projectKey: 'ORB', projectId: 'proj-1', total: 3, facts: [{ category: 'tech_stack', key: 'stack', value: 'Go and Postgres', observed: false }] }],
|
|
196
|
+
workspace: { total: 0, facts: [] },
|
|
197
|
+
});
|
|
198
|
+
const base = {
|
|
199
|
+
'/users/me/assigned-tickets': { items: [] },
|
|
200
|
+
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
201
|
+
'/agent-instructions': { instructions: 'RULE TEXT', rulesHash: 'h1' },
|
|
202
|
+
'/time/timer': {},
|
|
203
|
+
};
|
|
204
|
+
it('prints the layers and the facts right after the rules, and summarises the counts', async () => {
|
|
205
|
+
const calls = stubByPath({ ...base, '/project-facts': facts(FACTS_TEXT) });
|
|
206
|
+
const res = await makeSessionStartHandler(client)();
|
|
207
|
+
const text = res.content[0].text;
|
|
208
|
+
expect(calls).toContain('/project-facts');
|
|
209
|
+
expect(text).toContain(`RULE TEXT\n\n${FACTS_TEXT}\n\n## Your in-progress work`);
|
|
210
|
+
expect(res.structuredContent.projectFacts).toEqual({
|
|
211
|
+
source: 'session', projects: [{ projectKey: 'ORB', total: 3, shown: 1 }], workspace: { total: 0, shown: 0 }, unknown: [],
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
it('a long facts block is cut with a handle to the rest', async () => {
|
|
215
|
+
const long = `${FACTS_TEXT}\n${Array.from({ length: 80 }, (_, i) => `- other/fact-${i}: ${'v'.repeat(60)}`).join('\n')}`;
|
|
216
|
+
stubByPath({ ...base, '/project-facts': facts(long) });
|
|
217
|
+
const res = await makeSessionStartHandler(client)();
|
|
218
|
+
const text = res.content[0].text;
|
|
219
|
+
const handle = res.structuredContent.projectFacts.handle;
|
|
220
|
+
expect(handle).toBeTruthy();
|
|
221
|
+
expect(text).toContain(`orboto_response_expand { handle: "${handle}", path: "projectFacts" }`);
|
|
222
|
+
expect(text).not.toContain('fact-79');
|
|
223
|
+
});
|
|
224
|
+
it('an API without the block, or with a malformed one, leaves the section out', async () => {
|
|
225
|
+
stubByPath({ ...base, '/project-facts': { text: '## Project facts' } });
|
|
226
|
+
const res = await makeSessionStartHandler(client)();
|
|
227
|
+
expect(res.content[0].text).not.toContain('## Project facts');
|
|
228
|
+
expect(res.structuredContent.projectFacts).toBeUndefined();
|
|
229
|
+
});
|
|
230
|
+
});
|
|
178
231
|
describe('orboto_session_start - ticketKey bundle (ORB-1607)', () => {
|
|
179
232
|
const bundleStubs = {
|
|
180
233
|
'/projects/by-key/ORB': { id: 'proj-1', key: 'ORB', name: 'orboto' },
|
|
@@ -263,9 +316,28 @@ describe('orboto_session_start - context cost (ORB-1697)', () => {
|
|
|
263
316
|
expect(text).toContain('THE FULL RULE TEXT');
|
|
264
317
|
expect(text).not.toContain('forceRules=true');
|
|
265
318
|
});
|
|
266
|
-
it('
|
|
319
|
+
it('ORB-2576 - a subagent inside a running session gets the rules in full', async () => {
|
|
320
|
+
const dir = mkdtempSync(join(tmpdir(), 'orboto-mcp-subagent-'));
|
|
321
|
+
mkdirSync(join(dir, '.claude'), { recursive: true });
|
|
322
|
+
writeFileSync(join(dir, '.claude', 'settings.local.json'), JSON.stringify({ hooks: Object.fromEntries(ORBOTO_HOOK_EVENTS.map((e) => [e, [{ hooks: [{ type: 'command', command: 'orboto claude-hook' }] }]])) }));
|
|
323
|
+
const turn = (extra) => {
|
|
324
|
+
mkdirSync(join(dir, '.orboto', 'claude-turn'), { recursive: true });
|
|
325
|
+
writeFileSync(join(dir, '.orboto', 'claude-turn', 'sess-a.json'), JSON.stringify({ sessionId: 'sess-a', cwd: dir, state: 'idle', contextStartedAt: '2026-10-18T10:00:00Z', ...extra }));
|
|
326
|
+
};
|
|
327
|
+
const env = { CLAUDE_CODE_SESSION_ID: 'sess-a' };
|
|
267
328
|
const calls = stubByPath(rulesStub);
|
|
268
|
-
const handler = makeSessionStartHandler(client);
|
|
329
|
+
const handler = makeSessionStartHandler(client, { agentContext: () => agentContextKey(env, dir) });
|
|
330
|
+
turn({});
|
|
331
|
+
await handler();
|
|
332
|
+
turn({ activeSubagents: ['agent-1'], contextStartedAt: '2026-10-18T10:05:00Z' });
|
|
333
|
+
calls.length = 0;
|
|
334
|
+
const inSubagent = await handler();
|
|
335
|
+
expect(calls.find((c) => c.startsWith('/agent-instructions'))).toBe('/agent-instructions');
|
|
336
|
+
expect(inSubagent.content[0].text).toContain('THE FULL RULE TEXT');
|
|
337
|
+
});
|
|
338
|
+
it('without forceRules the ack path is still used inside one agent context - the saving is not lost', async () => {
|
|
339
|
+
const calls = stubByPath(rulesStub);
|
|
340
|
+
const handler = makeSessionStartHandler(client, { agentContext: () => 'claude-code sess-a 2026-10-18T10:00:00Z' });
|
|
269
341
|
await handler();
|
|
270
342
|
calls.length = 0;
|
|
271
343
|
await handler();
|
|
@@ -412,7 +484,7 @@ describe('ORB-1818 - rules index instead of the full rule text', () => {
|
|
|
412
484
|
const BLOCK_TITLES = Array.from({ length: 27 }, (_, i) => `Rule block number ${i + 1} - a realistic operator-authored title`);
|
|
413
485
|
const RULES_TEXT = BLOCK_TITLES.map((t, i) => `${t}\n${'x'.repeat(650)}${i}`).join('\n\n');
|
|
414
486
|
const RULES_INDEX = BLOCK_TITLES.map((title) => ({ title, chars: 651 }));
|
|
415
|
-
const rulesFull = { instructions: RULES_TEXT, rulesHash: 'hash1818aaaa', rulesIndex: RULES_INDEX, rulesChars: RULES_TEXT.length };
|
|
487
|
+
const rulesFull = { instructions: RULES_TEXT, rulesHash: 'hash1818aaaa', rulesIndex: RULES_INDEX, rulesChars: RULES_TEXT.length, rulesDelivery: 'index' };
|
|
416
488
|
function stubDigest(rules) {
|
|
417
489
|
return stubByPath({
|
|
418
490
|
'/users/me/assigned-tickets': {
|
|
@@ -469,9 +541,9 @@ describe('ORB-1818 - rules index instead of the full rule text', () => {
|
|
|
469
541
|
expect(chunks.join('')).toBe(RULES_TEXT);
|
|
470
542
|
});
|
|
471
543
|
it('an acked hash returns neither the text NOR the index', async () => {
|
|
472
|
-
const handler = makeSessionStartHandler(client);
|
|
544
|
+
const handler = makeSessionStartHandler(client, { agentContext: () => 'claude-code sess-a 2026-10-18T10:00:00Z' });
|
|
473
545
|
stubDigest(rulesFull);
|
|
474
|
-
await handler();
|
|
546
|
+
await handler({ forceRules: true });
|
|
475
547
|
vi.restoreAllMocks();
|
|
476
548
|
stubDigest({ rulesHash: 'hash1818aaaa', rulesUnchanged: true });
|
|
477
549
|
const second = await handler();
|
|
@@ -480,7 +552,7 @@ describe('ORB-1818 - rules index instead of the full rule text', () => {
|
|
|
480
552
|
expect(structured.rulesIndex).toBeUndefined();
|
|
481
553
|
expect(structured.rules).toBe('');
|
|
482
554
|
const text = second.content[0].text;
|
|
483
|
-
expect(text).toContain('Unchanged since this
|
|
555
|
+
expect(text).toContain('Unchanged since this agent context received them in full');
|
|
484
556
|
expect(text).toContain('rulesOnly=true');
|
|
485
557
|
expect(text).not.toContain('Rule block number 1');
|
|
486
558
|
});
|
|
@@ -521,6 +593,31 @@ describe('ORB-1818 - rules index instead of the full rule text', () => {
|
|
|
521
593
|
await handler({ rulesOnly: true });
|
|
522
594
|
expect(calls[0]).toBe('/agent-instructions');
|
|
523
595
|
});
|
|
596
|
+
it('ORB-2576 - a set under the bound comes in full even with an index', async () => {
|
|
597
|
+
stubDigest({ ...rulesFull, rulesDelivery: 'full' });
|
|
598
|
+
const res = await makeSessionStartHandler(client)();
|
|
599
|
+
expect(res.structuredContent.rulesDelivery).toBe('full');
|
|
600
|
+
expect(res.content[0].text).toContain(RULES_TEXT);
|
|
601
|
+
});
|
|
602
|
+
it('ORB-2576 - above the bound every must-read rule comes in full after the titles', async () => {
|
|
603
|
+
const index = RULES_INDEX.map((e, i) => (i === 1 ? { ...e, mustRead: true, body: 'MUST READ BODY' } : e));
|
|
604
|
+
stubDigest({ ...rulesFull, rulesIndex: index });
|
|
605
|
+
const res = await makeSessionStartHandler(client)();
|
|
606
|
+
const text = res.content[0].text;
|
|
607
|
+
expect(text).toContain('so the titles are printed and the 1 must-read rule(s) in full.');
|
|
608
|
+
expect(text).toContain(`2. ${BLOCK_TITLES[1]} (must read, in full below)`);
|
|
609
|
+
expect(text).toContain(`### Must read: ${BLOCK_TITLES[1]}\nMUST READ BODY`);
|
|
610
|
+
expect(res.structuredContent.mustRead).toEqual([BLOCK_TITLES[1]]);
|
|
611
|
+
});
|
|
612
|
+
it('ORB-2576 - an index delivery is no receipt: the next start in the same context sends no known hash', async () => {
|
|
613
|
+
const handler = makeSessionStartHandler(client, { agentContext: () => 'claude-code sess-a 2026-10-18T10:00:00Z' });
|
|
614
|
+
stubDigest(rulesFull);
|
|
615
|
+
await handler();
|
|
616
|
+
vi.restoreAllMocks();
|
|
617
|
+
const calls = stubDigest(rulesFull);
|
|
618
|
+
await handler();
|
|
619
|
+
expect(calls.find((c) => c.startsWith('/agent-instructions'))).toBe('/agent-instructions');
|
|
620
|
+
});
|
|
524
621
|
it('an API too old to send an index still delivers the full rules inline', async () => {
|
|
525
622
|
stubDigest({ instructions: RULES_TEXT, rulesHash: 'oldapi' });
|
|
526
623
|
const res = await makeSessionStartHandler(client)();
|
|
@@ -13,7 +13,7 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
|
13
13
|
const LOCALE_CODES = ['en', 'de', 'fr', 'es', 'it', 'nl', 'pt', 'ru', 'pl', 'tr', 'cs', 'da', 'sv', 'no', 'fi', 'ja', 'zh', 'ko'];
|
|
14
14
|
export const updateProjectToolConfig = {
|
|
15
15
|
title: 'Update a project\'s metadata',
|
|
16
|
-
description: 'Patch a project
|
|
16
|
+
description: 'Patch a project; set at least one field. `status` archived or closed leaves rotation. A `key` rename rewrites `PROJ-N` references. `language` null inherits. `noLaneReason`: no headless lane needed (bots work interactively); null clears.',
|
|
17
17
|
inputSchema: z.object({
|
|
18
18
|
projectKey: z.string().min(1).describe('Current project key.'),
|
|
19
19
|
patch: z.object({
|
|
@@ -28,6 +28,7 @@ export const updateProjectToolConfig = {
|
|
|
28
28
|
.describe('Customer UUID; null detaches.'),
|
|
29
29
|
language: z.enum(LOCALE_CODES).nullable().optional()
|
|
30
30
|
.describe('Content language; null inherits workspace.'),
|
|
31
|
+
noLaneReason: z.string().nullable().optional(),
|
|
31
32
|
}).refine((p) => Object.keys(p).length > 0, { message: 'patch must include at least one field' }),
|
|
32
33
|
}).shape,
|
|
33
34
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
@@ -49,6 +50,7 @@ export function makeUpdateProjectHandler(client) {
|
|
|
49
50
|
description: updated.description,
|
|
50
51
|
status: updated.status,
|
|
51
52
|
language: updated.language ?? null,
|
|
53
|
+
noLaneReason: updated.noLaneReason ?? null,
|
|
52
54
|
spec: updated.spec,
|
|
53
55
|
},
|
|
54
56
|
};
|
|
@@ -30,6 +30,16 @@ function stub(responses) {
|
|
|
30
30
|
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
31
31
|
const PROJ = { id: 'p1', key: 'ACME', name: 'Acme', description: null, status: 'active' };
|
|
32
32
|
describe('orboto_update_project', () => {
|
|
33
|
+
it('ORB-2569 records and clears the no-lane decision, string or null', async () => {
|
|
34
|
+
const calls = stub([{ json: PROJ }, { json: { ...PROJ, noLaneReason: 'Bots work interactively' } }, { json: PROJ }, { json: { ...PROJ, noLaneReason: null } }]);
|
|
35
|
+
const handler = makeUpdateProjectHandler(client);
|
|
36
|
+
const set = await handler({ projectKey: 'ACME', patch: { noLaneReason: 'Bots work interactively' } });
|
|
37
|
+
expect(calls[1]).toMatchObject({ method: 'PATCH', body: { noLaneReason: 'Bots work interactively' } });
|
|
38
|
+
expect(set.structuredContent).toMatchObject({ noLaneReason: 'Bots work interactively' });
|
|
39
|
+
const cleared = await handler({ projectKey: 'ACME', patch: { noLaneReason: null } });
|
|
40
|
+
expect(calls[3]).toMatchObject({ body: { noLaneReason: null } });
|
|
41
|
+
expect(cleared.structuredContent).toMatchObject({ noLaneReason: null });
|
|
42
|
+
});
|
|
33
43
|
it('PATCHes only the supplied fields', async () => {
|
|
34
44
|
const calls = stub([
|
|
35
45
|
{ json: PROJ },
|
|
@@ -16,6 +16,8 @@ import { VERSION } from './version.js';
|
|
|
16
16
|
const SRC = dirname(fileURLToPath(import.meta.url));
|
|
17
17
|
const pkg = createRequire(import.meta.url)('../package.json');
|
|
18
18
|
const ALLOWED = new Set(['version.ts']);
|
|
19
|
+
/** ORB-2584 - the channel-start cut-over is a protocol version pinned to shared-schema by channel-start.test.ts, not the package version. */
|
|
20
|
+
const ALLOWED_LINES = [/^export const CHANNEL_START_CUTOVER_VERSION = '\d+\.\d+\.\d+';$/];
|
|
19
21
|
function walk(dir, acc = []) {
|
|
20
22
|
for (const entry of readdirSync(dir)) {
|
|
21
23
|
const full = join(dir, entry);
|
|
@@ -38,7 +40,7 @@ describe('ORB-1173 - version literal guard', () => {
|
|
|
38
40
|
for (const file of walk(SRC)) {
|
|
39
41
|
const text = readFileSync(file, 'utf8');
|
|
40
42
|
text.split('\n').forEach((line, i) => {
|
|
41
|
-
if (semverLiteral.test(line))
|
|
43
|
+
if (semverLiteral.test(line) && !ALLOWED_LINES.some((allowed) => allowed.test(line.trim())))
|
|
42
44
|
offenders.push(`${file.replace(SRC, 'src')}:${i + 1}: ${line.trim()}`);
|
|
43
45
|
});
|
|
44
46
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orboto/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.224.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",
|