@orboto/mcp 0.222.0 → 0.223.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.
@@ -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 on the second', async () => {
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
- const handler = makeSessionStartHandler(client);
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 connection last delivered them');
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('without forceRules the ack path is still used - the saving is not lost', async () => {
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 connection last delivered them');
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 (`name`, `description`, `key`, `status`, `branchTemplate`, `customerId`, `language`). At least one field must be set. Use `status: "archived"` or `"closed"` to take a project out of active rotation. Renaming the `key` also rewrites every ticket\'s `PROJ-N` reference - handle with care. `language` sets the project\'s expected ticket language (one of the 18 supported locales); pass null to inherit the workspace language.',
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 },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orboto/mcp",
3
- "version": "0.222.0",
3
+ "version": "0.223.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",