@orboto/mcp 0.200.1 → 0.202.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.
@@ -41,6 +41,7 @@ export function readClaudeContext(dir, now = Date.now(), root = claudeContextDir
41
41
  ...(typeof parsed.contextWindowSize === 'number' && Number.isFinite(parsed.contextWindowSize)
42
42
  ? { contextWindowSize: Math.max(0, Math.round(parsed.contextWindowSize)) }
43
43
  : {}),
44
+ ...(typeof parsed.sessionId === 'string' && parsed.sessionId ? { sessionId: parsed.sessionId } : {}),
44
45
  });
45
46
  }
46
47
  return fresh.length === 1 ? fresh[0] : null;
@@ -6,7 +6,7 @@ import { describe, expect, it, beforeEach, afterEach } from 'vitest';
6
6
  import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
8
  import path from 'node:path';
9
- import { readClaudeContext } from './claude-context.js';
9
+ import { claudeContextDir, readClaudeContext } from './claude-context.js';
10
10
  let root;
11
11
  beforeEach(() => {
12
12
  root = mkdtempSync(path.join(tmpdir(), 'orboto-ctx-'));
@@ -36,3 +36,8 @@ describe('ORB-2149 - claude statusline context reports', () => {
36
36
  expect(readClaudeContext('/work/repo', Date.now(), path.join(root, 'missing'))).toBeNull();
37
37
  });
38
38
  });
39
+ describe('ORB-2149 - the report directory', () => {
40
+ it('lives under the orboto state directory of the user', () => {
41
+ expect(claudeContextDir().endsWith(path.join('.orboto', 'claude-context'))).toBe(true);
42
+ });
43
+ });
@@ -8,7 +8,7 @@ export const CONNECT_SCRIPT_HEADING = 'If you are an AI agent helping a person s
8
8
  export const CONNECT_SCRIPT_STEPS = [
9
9
  'Work one step per message: say in one sentence what the step does and why, run it, show the result, and only then move on.',
10
10
  'Run every step you can run yourself - `orboto connect --yes` checks Node and Claude Code, writes the `orboto` server entry for this directory and prints the exact start command with the standing arguments.',
11
- 'Hand the person only the steps that need a human: the browser login, quitting and restarting the session with that command, confirming the servers from `.mcp.json` and the local-development prompt, and, on a Team or Enterprise plan, the one Owner switch that allows channels.',
11
+ 'Hand the person only the steps that need a human: the browser login, the two `/plugin` commands that install the orboto plugin (a CLI cannot type into a running session), quitting and restarting the session with that command, confirming the servers from `.mcp.json` and, in the development form, the local-development prompt, and, on a Team or Enterprise plan, the one Owner switch that allows channels and lists the plugin.',
12
12
  'Give every command the person runs verbatim in its own code block, never paraphrased and never merged into prose.',
13
13
  'Verify before you continue: `orboto whoami` for the identity, `orboto mcp status` for the entry of this directory, `orboto connect --verify` for the channel.',
14
14
  'Never paste a reference page into the chat - link it and keep the message to the step you are on.',
@@ -1,3 +1,5 @@
1
+ import { RESTART_REQUESTED_NOTICE, writeRestartRequest } from './session-restart.js';
2
+ export { RESTART_REQUESTED_NOTICE };
1
3
  export const CHANNEL_METHOD = 'notifications/claude/channel';
2
4
  export const CHANNEL_CAPABILITY = 'claude/channel';
3
5
  export const DEFAULT_DIGEST_MINUTES = 15;
@@ -171,8 +173,24 @@ export class InboxChannel {
171
173
  if (!content)
172
174
  return;
173
175
  this.stats.notices += 1;
176
+ if (notice === RESTART_REQUESTED_NOTICE) {
177
+ await this.emit({ content: this.takeRestartRequest(content), meta: { kind: 'notice', notice } });
178
+ return;
179
+ }
174
180
  await this.emit({ content, meta: { kind: 'notice', notice } });
175
181
  }
182
+ /** ORB-2181 - an admin restart notice becomes the supervisor's request file. */
183
+ takeRestartRequest(reason) {
184
+ try {
185
+ const result = (this.opts.writeRestart ?? writeRestartRequest)(process.cwd(), { reason, source: 'channel' });
186
+ return result.written
187
+ ? `restart requested by the operator (${reason}) - the orboto claude supervisor restarts this session as soon as this turn ends and resumes the same conversation; Claude Code asks for the development-channel confirmation in that terminal.`
188
+ : `restart requested by the operator (${reason}), but nothing could be written: ${result.detail}. Run \`orboto claude --restart --reason "${reason}"\` in this session's terminal instead.`;
189
+ }
190
+ catch (err) {
191
+ return `restart requested by the operator (${reason}), but the request file could not be written: ${err.message}`;
192
+ }
193
+ }
176
194
  async flushDigest() {
177
195
  if (this.batch.length === 0)
178
196
  return;
@@ -279,6 +297,20 @@ export class InboxChannel {
279
297
  }
280
298
  }
281
299
  }
300
+ /** ORB-2175 - the notice name the stale-CLI finding is delivered under. */
301
+ export const CLI_OUTDATED_NOTICE = 'cli_outdated';
302
+ /**
303
+ * ORB-2175 - `orboto mcp serve` hands its version-skew finding to the proxy in
304
+ * ORBOTO_MCP_CLI_OUTDATED; the channel says it once on connect, because a CLI
305
+ * older than the instance leaves this session under a second identity.
306
+ */
307
+ export function cliOutdatedNotice(raw) {
308
+ const finding = (raw ?? '').trim();
309
+ if (!finding)
310
+ return '';
311
+ return `${finding}. Until then this checkout runs under two instance tokens, so this session is woken by its own edits. `
312
+ + 'Fix it yourself: run `orboto self-update`, then `orboto claude --restart --reason "CLI updated"` - the supervisor restarts this session on the same conversation as soon as your turn ends.';
313
+ }
282
314
  /** ORB-2140 - the digest window from the environment; NaN or negative fall back to the default. */
283
315
  export function digestMinutesFromEnv(raw) {
284
316
  if (raw === undefined || raw.trim() === '')
@@ -5,7 +5,7 @@
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
6
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
7
7
  import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
8
- import { CHANNEL_CAPABILITY, CHANNEL_INSTRUCTIONS, CHANNEL_METHOD, InboxChannel, digestMinutesFromEnv, isImmediate, renderEvent } from './inbox-channel.js';
8
+ import { CHANNEL_CAPABILITY, CHANNEL_INSTRUCTIONS, CHANNEL_METHOD, CLI_OUTDATED_NOTICE, InboxChannel, RESTART_REQUESTED_NOTICE, cliOutdatedNotice, digestMinutesFromEnv, isImmediate, renderEvent } from './inbox-channel.js';
9
9
  import { buildOrbotoMcpServer } from './server.js';
10
10
  function mockMcp() {
11
11
  const notification = vi.fn().mockResolvedValue(undefined);
@@ -115,6 +115,47 @@ describe('InboxChannel', () => {
115
115
  expect(String(fetchFn.mock.calls[1][0])).toContain('since=m1');
116
116
  channel.close();
117
117
  });
118
+ it('ORB-2181 - a restart_requested notice becomes the supervisor request file and says so', async () => {
119
+ const { mcp, notification } = mockMcp();
120
+ const writeRestart = vi.fn().mockReturnValue({ written: true, sessionId: 'sess-1', path: '/home/a/.orboto/claude/sess-1/restart.json', detail: 'ok' });
121
+ const fetchFn = vi.fn().mockImplementation(() => new Promise(() => { }));
122
+ const channel = new InboxChannel({ baseUrl: 'https://x.test', apiKey: 'orb_k', instanceToken: 'mcp-proc', mcp, fetchFn, log: () => { }, digestMinutes: 0, writeRestart });
123
+ await channel.deliverNotice(RESTART_REQUESTED_NOTICE, 'the CLI was updated');
124
+ expect(writeRestart).toHaveBeenCalledWith(process.cwd(), { reason: 'the CLI was updated', source: 'channel' });
125
+ const sent = notification.mock.calls[0][0];
126
+ expect(sent.params.meta).toEqual({ kind: 'notice', notice: 'restart_requested' });
127
+ expect(sent.params.content).toContain('resumes the same conversation');
128
+ expect(channel.stats).toMatchObject({ notices: 1, delivered: 0 });
129
+ channel.close();
130
+ });
131
+ it('ORB-2181 - a restart_requested notice that cannot be written says why instead of pretending', async () => {
132
+ const { mcp, notification } = mockMcp();
133
+ const writeRestart = vi.fn().mockReturnValue({ written: false, sessionId: null, path: null, detail: 'no status line report' });
134
+ const fetchFn = vi.fn().mockImplementation(() => new Promise(() => { }));
135
+ const channel = new InboxChannel({ baseUrl: 'https://x.test', apiKey: 'orb_k', instanceToken: 'mcp-proc', mcp, fetchFn, log: () => { }, digestMinutes: 0, writeRestart });
136
+ await channel.deliverNotice(RESTART_REQUESTED_NOTICE, 'rotated key');
137
+ const sent = notification.mock.calls[0][0];
138
+ expect(sent.params.content).toContain('no status line report');
139
+ channel.close();
140
+ });
141
+ it('ORB-2175 - the stale-CLI finding from `mcp serve` becomes one cli_outdated notice on connect', async () => {
142
+ expect(cliOutdatedNotice(undefined)).toBe('');
143
+ expect(cliOutdatedNotice(' ')).toBe('');
144
+ const finding = 'CLI 0.200.0 is older than the instance 0.201.0 - run `orboto self-update` and restart this session';
145
+ const content = cliOutdatedNotice(finding);
146
+ expect(content).toContain(finding);
147
+ expect(content).toContain('two instance tokens');
148
+ const { mcp, notification } = mockMcp();
149
+ const fetchFn = vi.fn().mockImplementation(() => new Promise(() => { }));
150
+ const channel = new InboxChannel({ baseUrl: 'https://x.test', apiKey: 'orb_k', instanceToken: 'mcp-proc', mcp, fetchFn, log: () => { }, digestMinutes: 0 });
151
+ await channel.deliverNotice(CLI_OUTDATED_NOTICE, content);
152
+ expect(notification).toHaveBeenCalledTimes(1);
153
+ const sent = notification.mock.calls[0][0];
154
+ expect(sent.params.meta).toEqual({ kind: 'notice', notice: 'cli_outdated' });
155
+ expect(sent.params.content).toBe(content);
156
+ expect(channel.stats).toMatchObject({ notices: 1, delivered: 0 });
157
+ channel.close();
158
+ });
118
159
  });
119
160
  describe('server capability', () => {
120
161
  async function connect(channel) {
package/dist/index.js CHANGED
@@ -59,11 +59,14 @@ async function main() {
59
59
  const stdio = new StdioServerTransport();
60
60
  await server.connect(stdio);
61
61
  if (channel) {
62
- const { InboxChannel, digestMinutesFromEnv } = await import('./inbox-channel.js');
62
+ const { InboxChannel, digestMinutesFromEnv, cliOutdatedNotice, CLI_OUTDATED_NOTICE } = await import('./inbox-channel.js');
63
63
  const { mcpProcessInstance } = await import('./tools/shared.js');
64
64
  const instanceToken = mcpProcessInstance();
65
65
  const inbox = new InboxChannel({ ...clientConfig, instanceToken, mcp: server, digestMinutes: digestMinutesFromEnv(process.env.ORBOTO_MCP_CHANNEL_DIGEST_MINUTES) });
66
66
  inbox.start();
67
+ const outdated = cliOutdatedNotice(process.env.ORBOTO_MCP_CLI_OUTDATED);
68
+ if (outdated)
69
+ void inbox.deliverNotice(CLI_OUTDATED_NOTICE, outdated);
67
70
  const { readClaudeContext } = await import('./claude-context.js');
68
71
  const beat = () => {
69
72
  const context = readClaudeContext(process.cwd());
@@ -39,10 +39,16 @@ afterEach(() => { vi.restoreAllMocks(); });
39
39
  * the wake ledger and the delivery precision per session and per sender
40
40
  * in the full toolset (measured full = 170,030 chars); the curated tier
41
41
  * reaches it through the escape hatch and is unchanged.
42
+ * ORB-2162 raised the curated ceiling to 39,900 and the curated token
43
+ * ceiling to 11,100: `orboto_create_ticket` gained `dryRun`, the pre-flight
44
+ * that returns the language, type, label and duplicate verdicts before the
45
+ * write instead of after it (measured curated = 39,679 chars / 11,022
46
+ * tokens, from 39,461 / 10,961 - one boolean parameter, no new tool). The
47
+ * verdicts themselves ride the response, not the manifest.
42
48
  *
43
49
  * @see ORB-1805, ORB-1669, ORB-1910
44
50
  */
45
- const CURATED_MAX_CHARS = 39_700;
51
+ const CURATED_MAX_CHARS = 39_900;
46
52
  const FULL_MAX_CHARS = 171_200;
47
53
  /**
48
54
  * ORB-1805 - the estimator the ticket measured the failure with
@@ -64,7 +70,7 @@ const MINIMAL_MAX_TOKENS = 3_200;
64
70
  * The curated tier is ratcheted on its TOOL SCHEMAS (the instructions
65
71
  * block has its own budget, enforced in instructions-budget.test.ts).
66
72
  */
67
- const CURATED_SCHEMA_MAX_TOKENS = 11_000;
73
+ const CURATED_SCHEMA_MAX_TOKENS = 11_100;
68
74
  async function measuredManifest(toolset) {
69
75
  const server = await buildOrbotoMcpServer({
70
76
  baseUrl: 'https://orboto.example.com',
@@ -59,6 +59,10 @@ const MIN_ARRAY_CUT = 3;
59
59
  const MAX_CUT_PASSES = 64;
60
60
  export const HANDLE_TTL_MS = 15 * 60 * 1000;
61
61
  export const MAX_HANDLES = 16;
62
+ /** ORB-2164 - the budget is an MCP-server artefact; the same data over REST or
63
+ * either CLI comes back whole, which is the way out that outlives a handle. */
64
+ export const UNCUT_ELSEWHERE = 'The cut is an MCP-server artefact: the REST API and both CLIs return the complete body '
65
+ + '(`orboto get <path>`, `orboto_api_call`, or the named CLI command). There is no CLI expand.';
62
66
  /**
63
67
  * `ORBOTO_MCP_RESPONSE_BUDGET=off` disables the cap entirely (escape
64
68
  * hatch for a client that genuinely wants everything);
@@ -326,7 +330,8 @@ function shrink(toolName, result, budget, originalChars, protectText = false) {
326
330
  const notice = allOmitted.length > 0
327
331
  ? `[Response truncated to the MCP response budget (${budget} chars) - it would otherwise cost `
328
332
  + `${originalChars} chars on EVERY later request in this session. Omitted content is not lost: `
329
- + `call orboto_response_expand with handle "${handle}" and one of the paths in __truncation.omitted.]`
333
+ + `call orboto_response_expand with handle "${handle}" and one of the paths in __truncation.omitted. `
334
+ + `${UNCUT_ELSEWHERE}]`
330
335
  : `[Response is ${originalChars} chars, over the MCP response budget (${budget} chars), and nothing in it `
331
336
  + 'could be cut safely - it is protected or uncuttable content. NOTHING was omitted; there is no remainder to fetch.]';
332
337
  if (structured !== undefined && structured !== null && typeof structured === 'object' && !Array.isArray(structured)) {
@@ -337,7 +342,8 @@ function shrink(toolName, result, budget, originalChars, protectText = false) {
337
342
  omittedChars: 0, // filled in below, once the final size is known
338
343
  omitted: allOmitted,
339
344
  howToGetTheRest: `Call orboto_response_expand { handle: "${handle}", path: "<one of omitted[].path>" } for the omitted `
340
- + 'remainder. Omit `path` to list what is available. The handle expires 15 minutes after this call.',
345
+ + 'remainder. Omit `path` to list what is available. The handle lives in this MCP server process and '
346
+ + `expires 15 minutes after this call. ${UNCUT_ELSEWHERE}`,
341
347
  ...(atFloor ? { atFloor: true } : {}),
342
348
  };
343
349
  structured.__truncation = block;
@@ -2,7 +2,7 @@
2
2
  * ORB-1697 - the central response budget.
3
3
  */
4
4
  import { describe, it, expect, beforeEach } from 'vitest';
5
- import { applyResponseBudget, budgetFor, budgetEnabled, measureHalves, measureResult, readPayload, resetPayloadStore, resolvePath, DEFAULT_BUDGET_CHARS, MAX_HANDLES, HANDLE_TTL_MS, storePayload, PROTECT_TEXT_META, } from './response-budget.js';
5
+ import { applyResponseBudget, budgetFor, budgetEnabled, measureHalves, measureResult, readPayload, resetPayloadStore, resolvePath, DEFAULT_BUDGET_CHARS, MAX_HANDLES, HANDLE_TTL_MS, storePayload, PROTECT_TEXT_META, UNCUT_ELSEWHERE, } from './response-budget.js';
6
6
  import { makeResponseExpandHandler } from './tools/response-expand.js';
7
7
  const TOOL = 'orboto_test_tool';
8
8
  function result(structured, text = 'text block') {
@@ -84,6 +84,19 @@ describe('over budget', () => {
84
84
  expect(text).toContain('orboto_response_expand');
85
85
  expect(text).toContain(out.handle);
86
86
  });
87
+ it('ORB-2164: the hint names BOTH ways out - the expand handle and the uncut body over REST or the CLI', () => {
88
+ const out = applyResponseBudget(TOOL, result({ description: 'd'.repeat(30_000) }));
89
+ const block = truncationOf(out.result);
90
+ const how = String(block.howToGetTheRest);
91
+ expect(how).toContain('orboto_response_expand');
92
+ expect(how).toContain('expires 15 minutes');
93
+ expect(how).toContain('this MCP server process');
94
+ expect(how).toContain(UNCUT_ELSEWHERE);
95
+ const text = out.result.content[0].text;
96
+ expect(text).toContain(UNCUT_ELSEWHERE);
97
+ expect(UNCUT_ELSEWHERE).toContain('REST API and both CLIs');
98
+ expect(UNCUT_ELSEWHERE).toContain('no CLI expand');
99
+ });
87
100
  it('marks the cut inside the value, so a reader cannot mistake it for the whole', () => {
88
101
  const out = applyResponseBudget(TOOL, result({ description: 'd'.repeat(30_000) }));
89
102
  const sc = out.result.structuredContent;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * ORB-2181 - the proxy half of the session restart: whatever asked for it -
3
+ * the agent through `orboto_session_restart` or the operator through the
4
+ * `restart_requested` channel notice - ends as the same request file the
5
+ * `orboto claude` supervisor consumes. No socket, no signal, no network
6
+ * listener: one 0600 file in a 0700 directory of the caller's own home.
7
+ */
8
+ import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { homedir } from 'node:os';
10
+ import path from 'node:path';
11
+ import { claudeContextDir, readClaudeContext } from './claude-context.js';
12
+ export const RESTART_REQUESTED_NOTICE = 'restart_requested';
13
+ export function claudeSessionsDir() {
14
+ return path.join(homedir(), '.orboto', 'claude');
15
+ }
16
+ /** Keeps a session id from escaping the session root. */
17
+ export function safeSessionName(id) {
18
+ const cleaned = [...id].filter((c) => /[A-Za-z0-9_-]/.test(c)).join('').slice(0, 80);
19
+ return cleaned || 'session';
20
+ }
21
+ export function writeRestartRequest(dir, input, roots = {}) {
22
+ const now = roots.now ?? Date.now();
23
+ const root = roots.root ?? claudeSessionsDir();
24
+ const report = readClaudeContext(dir, now, roots.contextRoot ?? claudeContextDir());
25
+ const sessionId = report?.sessionId;
26
+ if (!sessionId) {
27
+ return {
28
+ written: false,
29
+ sessionId: null,
30
+ path: null,
31
+ detail: 'no status line report identifies a Claude Code session in this directory - wire the reporter up with `orboto mcp install --statusline`, then ask again',
32
+ };
33
+ }
34
+ const target = path.join(root, safeSessionName(sessionId));
35
+ const body = JSON.stringify({
36
+ reason: input.reason,
37
+ source: input.source,
38
+ requestedAt: new Date(now).toISOString(),
39
+ ...(input.requestedBy ? { requestedBy: input.requestedBy } : {}),
40
+ idleTimeoutSeconds: input.idleTimeoutSeconds ?? 0,
41
+ });
42
+ const staging = path.join(target, `.restart-${process.pid}.tmp`);
43
+ const file = path.join(target, 'restart.json');
44
+ mkdirSync(target, { recursive: true, mode: 0o700 });
45
+ writeFileSync(staging, body, { mode: 0o600 });
46
+ try {
47
+ renameSync(staging, file);
48
+ }
49
+ catch (err) {
50
+ rmSync(staging, { force: true });
51
+ throw err;
52
+ }
53
+ return {
54
+ written: true,
55
+ sessionId,
56
+ path: file,
57
+ detail: 'the supervisor restarts this session as soon as no turn is running; it never interrupts one',
58
+ };
59
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * ORB-2181 - the proxy writes one 0600 request file into a 0700 directory
3
+ * named after the Claude Code session, and refuses when no status line
4
+ * report names one.
5
+ */
6
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
7
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import path from 'node:path';
10
+ import { claudeSessionsDir, safeSessionName, writeRestartRequest } from './session-restart.js';
11
+ let contexts;
12
+ let sessions;
13
+ const NOW = Date.parse('2026-09-19T10:00:00Z');
14
+ beforeEach(() => {
15
+ contexts = mkdtempSync(path.join(tmpdir(), 'orboto-ctx-'));
16
+ sessions = mkdtempSync(path.join(tmpdir(), 'orboto-sessions-'));
17
+ });
18
+ afterEach(() => {
19
+ rmSync(contexts, { recursive: true, force: true });
20
+ rmSync(sessions, { recursive: true, force: true });
21
+ });
22
+ function report(body, root = contexts) {
23
+ mkdirSync(root, { recursive: true });
24
+ writeFileSync(path.join(root, 'report.json'), JSON.stringify(body));
25
+ }
26
+ describe('ORB-2181 - the restart request file', () => {
27
+ it('writes the request under the reported session id, owner only', () => {
28
+ report({ cwd: '/work/repo', sessionId: 'sess-7f3a2b1c', contextTokens: 10, updatedAt: new Date(NOW).toISOString() });
29
+ const result = writeRestartRequestHere('/work/repo', 'the CLI was updated');
30
+ expect(result.written).toBe(true);
31
+ expect(result.sessionId).toBe('sess-7f3a2b1c');
32
+ const file = path.join(sessions, 'sess-7f3a2b1c', 'restart.json');
33
+ expect(result.path).toBe(file);
34
+ expect(statSync(file).mode & 0o777).toBe(0o600);
35
+ expect(statSync(path.dirname(file)).mode & 0o777).toBe(0o700);
36
+ expect(JSON.parse(readFileSync(file, 'utf8'))).toMatchObject({ reason: 'the CLI was updated', source: 'mcp' });
37
+ });
38
+ it('refuses without a status line report and names the fix', () => {
39
+ const result = writeRestartRequestHere('/work/repo', 'rotated key');
40
+ expect(result.written).toBe(false);
41
+ expect(result.sessionId).toBeNull();
42
+ expect(result.detail).toContain('orboto mcp install --statusline');
43
+ });
44
+ it('keeps a session id from escaping the session root', () => {
45
+ expect(safeSessionName('../../etc/passwd')).toBe('etcpasswd');
46
+ expect(safeSessionName('!!!')).toBe('session');
47
+ });
48
+ });
49
+ function writeRestartRequestHere(dir, reason) {
50
+ return writeRestartRequest(dir, { reason, source: 'mcp' }, { now: NOW, root: sessions, contextRoot: contexts });
51
+ }
52
+ describe('ORB-2181 - the session root', () => {
53
+ it('lives under the orboto state directory of the user', () => {
54
+ expect(claudeSessionsDir().endsWith(path.join('.orboto', 'claude'))).toBe(true);
55
+ });
56
+ });
@@ -45,6 +45,10 @@ export const AgentInventoryEntrySchema = z.object({
45
45
  wrongWakeContextTokens: z.number().int(),
46
46
  backlogSeen: z.number().int(),
47
47
  }),
48
+ /** ORB-2181 - a restart was requested for this session and its supervisor has not reconnected yet. */
49
+ restartPending: z.boolean(),
50
+ /** ORB-2181 - the reason the requester gave; null when nothing is pending. */
51
+ restartReason: z.string().nullable(),
48
52
  lastSeenAt: z.string(),
49
53
  createdAt: z.string(),
50
54
  });
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { makeAgentWakesHandler } from './agent-wakes.js';
3
+ function clientReturning(body) {
4
+ const get = vi.fn().mockResolvedValue(body);
5
+ return { client: { get }, get };
6
+ }
7
+ function textOf(result) {
8
+ return result.content[0].text;
9
+ }
10
+ describe('orboto_agent_wakes', () => {
11
+ it('renders the stats with precision, wrong-wake cost and backlog per session and sender', async () => {
12
+ const { client, get } = clientReturning({
13
+ hours: 12,
14
+ sessions: [
15
+ { label: 'lead (ab12cd34)', wakes: 4, precision: 0.75, medianLatencyMs: 20, topRule: 'addressed', wrongWakeContextTokens: 1200, backlogSeen: 3 },
16
+ { label: 'worker (ef56ab78)', wakes: 1, precision: null, medianLatencyMs: null, topRule: null, wrongWakeContextTokens: 0, backlogSeen: 0 },
17
+ ],
18
+ senders: [],
19
+ });
20
+ const result = await makeAgentWakesHandler(client)({ stats: true, hours: 12, sessionId: 'ab12cd34' });
21
+ expect(get).toHaveBeenCalledWith('/admin/agents/wake-stats?sessionId=ab12cd34&hours=12');
22
+ const text = textOf(result);
23
+ expect(text).toContain('Last 12 h - woken sessions:');
24
+ expect(text).toContain('- lead (ab12cd34): 4 wakes, precision 75%, median 20 ms, top rule addressed, wrong wakes cost 1200 tokens, 3 backlog seen');
25
+ expect(text).toContain('- worker (ef56ab78): 1 wakes, precision unjudged, median ? ms, top rule -');
26
+ expect(text).toContain('Senders:\n- (none)');
27
+ });
28
+ it('says so when nothing woke anyone', async () => {
29
+ const { client } = clientReturning({ hours: 24, sessions: [], senders: [] });
30
+ expect(textOf(await makeAgentWakesHandler(client)({ stats: true }))).toContain('- (no wakes)');
31
+ });
32
+ it('lists the ledger rows with latency, context cost and the next cursor', async () => {
33
+ const { client, get } = clientReturning({
34
+ rows: [
35
+ { wokenAt: '2026-01-02T03:04:05Z', subject: 'probe', senderLabel: 'lead (ab12cd34)', rule: 'addressed', transport: 'channel', outcome: 'handled', latencyMs: 18, contextTokens: 900 },
36
+ { wokenAt: '2026-01-02T03:05:05Z', subject: 'digest', senderLabel: 'worker (ef56ab78)', rule: 'scope_project', transport: 'poll', outcome: 'pending', latencyMs: null, contextTokens: null },
37
+ ],
38
+ nextCursor: 'abc',
39
+ });
40
+ const text = textOf(await makeAgentWakesHandler(client)({ project: 'ACME', outcome: 'handled', limit: 2, cursor: 'c0' }));
41
+ expect(get).toHaveBeenCalledWith('/v1/agent/wakes?project=ACME&outcome=handled&limit=2&cursor=c0');
42
+ expect(text).toContain('- 2026-01-02T03:04:05Z probe - from lead (ab12cd34) via addressed/channel -> handled (18 ms, 900 ctx tokens)');
43
+ expect(text).toContain('via scope_project/poll -> pending');
44
+ expect(text).toContain('(next cursor: abc)');
45
+ });
46
+ it('reports an empty ledger', async () => {
47
+ const { client } = clientReturning({ rows: [], nextCursor: null });
48
+ expect(textOf(await makeAgentWakesHandler(client)({}))).toBe('(no wakes recorded)');
49
+ });
50
+ });
@@ -82,7 +82,7 @@ export function makeListDocCommentsHandler(client) {
82
82
  }
83
83
  export const postDocCommentToolConfig = {
84
84
  title: 'Post a comment on a doc page (or reply to one)',
85
- description: 'Add a comment to a doc page. Pass `parentCommentId` to reply (the API flattens reply chains past one level so a reply-of-reply lands as a sibling of the original reply). Optional `anchor` (`{text, before, after}`) attaches the comment to a specific highlight in the body - the frontend uses the surrounding context to re-locate the anchor even after the doc has been edited. Mentions in the content body (`@username`) fire notifications automatically.',
85
+ description: 'Add a comment to a doc page. Pass `parentCommentId` to reply (the API flattens reply chains past one level so a reply-of-reply lands as a sibling of the original reply). Optional `anchor` (`{text, before, after}`) attaches the comment to a specific highlight in the body - the frontend uses the surrounding context to re-locate the anchor even after the doc has been edited. Mention a person with the markup `@[Full Name](user:<uuid>)` - the UUIDs come from `orboto_list_users`. The response reports `mentions` (tokens that resolved to a user, who is then notified) and `unresolvedMentions` (tokens that named nobody); a mention is never silently dropped.',
86
86
  inputSchema: z.object({
87
87
  docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
88
88
  content: z.string().min(1).max(4000),
@@ -104,10 +104,20 @@ export function makePostDocCommentHandler(client) {
104
104
  if (anchor)
105
105
  body.anchor = anchor;
106
106
  const row = await client.post(`/docs/${docId}/comments`, body);
107
+ const resolved = row.mentions ?? [];
108
+ const unresolved = row.unresolvedMentions ?? [];
109
+ const mentionParts = [];
110
+ if (resolved.length > 0)
111
+ mentionParts.push(`notified ${resolved.map((m) => m.fullName ?? m.email).join(', ')}`);
112
+ if (unresolved.length > 0)
113
+ mentionParts.push(`UNRESOLVED ${unresolved.map((m) => `${m.token} (${m.reason})`).join(', ')}`);
107
114
  return {
108
115
  content: [{
109
116
  type: 'text',
110
- text: `Posted comment ${row.id} on doc ${docId}${row.parentCommentId ? ` (reply to ${row.parentCommentId})` : ''}.`,
117
+ text: [
118
+ `Posted comment ${row.id} on doc ${docId}${row.parentCommentId ? ` (reply to ${row.parentCommentId})` : ''}.`,
119
+ ...(mentionParts.length > 0 ? [`Mentions: ${mentionParts.join(' · ')}`] : []),
120
+ ].join('\n'),
111
121
  }],
112
122
  structuredContent: {
113
123
  id: row.id,
@@ -116,6 +126,8 @@ export function makePostDocCommentHandler(client) {
116
126
  content: row.content,
117
127
  anchor: row.anchor,
118
128
  createdAt: row.createdAt,
129
+ mentions: resolved,
130
+ unresolvedMentions: unresolved,
119
131
  },
120
132
  };
121
133
  };
@@ -90,7 +90,7 @@ function mimetypeFor(filename) {
90
90
  }
91
91
  export const ingestFileToolConfig = {
92
92
  title: 'Ingest a local file into a wiki space (multipart upload)',
93
- description: 'Upload a file (PDF / DOCX / Markdown / plain text) into `spaceId` via multipart. The model passes the bytes as a base64 `contentBase64` field (so this works without local FS access on the agent side). Use `filename` to give the doc a meaningful title - the backend sniffs the mimetype but also uses the filename for display. Optional `parentDocId` nests under an existing doc.',
93
+ description: 'Upload a file (PDF / DOCX / Markdown / plain text) into `spaceId` via multipart. The model passes the bytes as a base64 `contentBase64` field (so this works without local FS access on the agent side). Use `filename` to give the doc a meaningful title - the backend sniffs the mimetype but also uses the filename for display. Optional `parentDocId` nests under an existing doc. The uploaded file is ALSO attached to the doc it produces (ORB-2167): the response names `attachmentId`, `orboto_list_doc_attachments` lists it and `orboto_get_attachment` downloads the original bytes, so the extracted text and its source stay together.',
94
94
  inputSchema: z.object({
95
95
  spaceId: z.string().uuid(),
96
96
  filename: z.string().min(1).describe('Display filename, e.g. "ADR-12-secrets-rotation.pdf".'),
@@ -124,6 +124,7 @@ export function makeIngestFileHandler(client) {
124
124
  ` slug: ${res.slug}`,
125
125
  ` format: ${res.kind}`,
126
126
  ` uploaded ${Math.round(res.sizeBytes / 1024)} KB → ${res.markdownChars} chars of markdown`,
127
+ ` source attached: ${res.attachmentId ?? '(none)'}`,
127
128
  ];
128
129
  return {
129
130
  content: [{ type: 'text', text: lines.join('\n') }],
@@ -134,6 +135,7 @@ export function makeIngestFileHandler(client) {
134
135
  kind: res.kind,
135
136
  sizeBytes: res.sizeBytes,
136
137
  markdownChars: res.markdownChars,
138
+ attachmentId: res.attachmentId ?? null,
137
139
  },
138
140
  };
139
141
  };
@@ -276,7 +276,7 @@ export const createDocToolConfig = {
276
276
  inputSchema: z.object({
277
277
  spaceId: z.string().min(1).describe('Target doc space - key (e.g. ORB-S1), name, or UUID. Discover via orboto_list_doc_spaces.'),
278
278
  title: z.string().min(1).max(255),
279
- content: z.string().optional().describe('Markdown body. Pass an empty string or omit for a blank page.'),
279
+ content: z.string().optional().describe('Markdown body (CommonMark + GFM). Pass an empty string or omit for a blank page. Images are inline elements; status pills use `:badge[LABEL]{tone=success}` (neutral/info/success/warning/danger/accent, unknown reads as neutral, label <= 24 chars).'),
280
280
  parentDocId: z.string().uuid().nullable().optional().describe('Nest under another doc.'),
281
281
  visibility: z.enum(['public', 'workspace', 'members', 'specific']).optional().describe('Defaults to "workspace".'),
282
282
  icon: z.string().nullish().describe('Single emoji shown in the tree.'),
@@ -315,7 +315,7 @@ export const updateDocToolConfig = {
315
315
  inputSchema: z.object({
316
316
  docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
317
317
  title: z.string().min(1).max(255).optional(),
318
- content: z.string().optional().describe('Markdown body. Send the full new content; the API replaces, not appends.'),
318
+ content: z.string().optional().describe('Markdown body (CommonMark + GFM). Send the full new content; the API replaces, not appends. Images are inline elements; status pills use `:badge[LABEL]{tone=success}` (neutral/info/success/warning/danger/accent, unknown reads as neutral, label <= 24 chars).'),
319
319
  parentDocId: z.string().uuid().nullable().optional(),
320
320
  visibility: z.enum(['public', 'workspace', 'members', 'specific']).optional(),
321
321
  icon: z.string().nullish(),
@@ -7,7 +7,7 @@ import { z } from 'zod';
7
7
  import { resolveProjectByKey } from './shared.js';
8
8
  export const getProjectPrimerToolConfig = {
9
9
  title: 'Get project primer (AI Context Pack)',
10
- description: 'Returns the project\'s auto-generated session primer in a single call: active milestones, ticket counts, recently-closed milestones, top docs, structured primer facts, and recent activity. Primer facts merge three scopes in order Workspace -> Project -> Personal: the Personal block ("Your preferences") is the calling user\'s own personal-scope facts, included only when that user has opted in (ai_preferences_enabled) and never showing another user\'s. Repo briefings (CLAUDE.md / AGENTS.md) are included ONLY when the operator configured them AND the API host can read those files from disk - most deployments will not have them, so do not assume CLAUDE.md / AGENTS.md content is in the response. Token-budget aware - pass `maxTokens` to constrain the output. Input is the project key (e.g. "ORB"), case-insensitive.',
10
+ description: 'Returns the project\'s auto-generated session primer in a single call: the project vocabulary a create has to get right (ticket type enum, the project\'s labels, its status names per category, its open milestone keys), active milestones, ticket counts, recently-closed milestones, top docs, structured primer facts, and recent activity. Primer facts merge three scopes in order Workspace -> Project -> Personal: the Personal block ("Your preferences") is the calling user\'s own personal-scope facts, included only when that user has opted in (ai_preferences_enabled) and never showing another user\'s. Repo briefings (CLAUDE.md / AGENTS.md) are included ONLY when the operator configured them AND the API host can read those files from disk - most deployments will not have them, so do not assume CLAUDE.md / AGENTS.md content is in the response. Token-budget aware - pass `maxTokens` to constrain the output. Input is the project key (e.g. "ORB"), case-insensitive.',
11
11
  inputSchema: z.object({
12
12
  projectKey: z.string().min(1).describe('Project key, case-insensitive.'),
13
13
  maxTokens: z.number().int().positive().max(200000).optional()
@@ -6,11 +6,11 @@ import { specReleaseInfo } from './shared.js';
6
6
  */
7
7
  import { z } from 'zod';
8
8
  import { OrbotoApiError } from '../orboto-client.js';
9
- import { resolveTicketByKey } from './shared.js';
9
+ import { formatDependencySummary, resolveTicketByKey } from './shared.js';
10
10
  const COMMENT_PAGE_SIZE = 50;
11
11
  export const getTicketToolConfig = {
12
12
  title: 'Get ticket details',
13
- description: 'Return a ticket\'s decision card: description, status, priority, milestone, assignees, labels, dates, estimate/logged time, checklist progress, parent + sub-ticket count - plus COUNTS for everything omitted (commentCount, gitActivityCount, attachmentCount, childCount). Full blocks are opt-in via `include` (ORB-1698): pass e.g. include: ["comments"] to get the comment bodies, ["git","attachments","children","raci","checklistItems"] likewise. Input is the ticket key like "ACME-42".',
13
+ description: 'Return a ticket\'s decision card: description, status, priority, milestone, assignees, labels, dates, estimate/logged time, checklist progress, parent + sub-ticket count, the OPEN dependency edges in both directions ("Blocked by" / "Blocks", keys + statuses - the full graph stays on orboto_list_ticket_dependencies) - plus COUNTS for everything omitted (commentCount, gitActivityCount, attachmentCount, childCount). Full blocks are opt-in via `include` (ORB-1698): pass e.g. include: ["comments"] to get the comment bodies, ["git","attachments","children","raci","checklistItems"] likewise. Input is the ticket key like "ACME-42".',
14
14
  inputSchema: z.object({
15
15
  ticketKey: z.string().min(3).describe('Ticket key like "ACME-42".'),
16
16
  include: z.array(z.enum(['comments', 'git', 'attachments', 'children', 'raci', 'checklistItems']))
@@ -91,6 +91,15 @@ export function makeGetTicketHandler(client) {
91
91
  statusCategory: parent.statusCategory ?? null,
92
92
  } : null,
93
93
  childCount: children.length,
94
+ webUrl: full.webUrl ?? null,
95
+ blockedBy: {
96
+ openCount: full.blockedByOpenCount ?? 0,
97
+ tickets: (full.blockedByOpen ?? []).map(dependencyEdge),
98
+ },
99
+ blocks: {
100
+ openCount: full.blocksOpenCount ?? 0,
101
+ tickets: (full.blocksOpen ?? []).map(dependencyEdge),
102
+ },
94
103
  ...(inc.has('children') ? {
95
104
  children: children.map((c) => ({
96
105
  key: c.ticketKey,
@@ -166,6 +175,14 @@ function swallow404(fallback) {
166
175
  throw err;
167
176
  };
168
177
  }
178
+ function dependencyEdge(e) {
179
+ return {
180
+ key: e.ticketKey,
181
+ title: e.title,
182
+ status: e.statusName ?? null,
183
+ ...(e.external ? { external: true } : {}),
184
+ };
185
+ }
169
186
  function formatTicket(ticket, inc, comments, hasMoreComments, checklists, gitActivity, parent, children, attachments, includeHint) {
170
187
  const header = [
171
188
  `[${ticket.ticketKey}] ${ticket.title}`,
@@ -197,6 +214,9 @@ function formatTicket(ticket, inc, comments, hasMoreComments, checklists, gitAct
197
214
  ticket.labels && ticket.labels.length > 0
198
215
  ? `Labels: ${ticket.labels.map((l) => l.name).join(', ')}`
199
216
  : null,
217
+ ticket.webUrl ? `Link: ${ticket.webUrl}` : null,
218
+ formatDependencySummary('Blocked by', ticket.blockedByOpenCount, ticket.blockedByOpen),
219
+ formatDependencySummary('Blocks', ticket.blocksOpenCount, ticket.blocksOpen),
200
220
  ].filter((s) => s !== null);
201
221
  if (inc.has('children') && children.length > 0) {
202
222
  header.push('', 'Children:');
@@ -4,7 +4,7 @@
4
4
  import { z } from 'zod';
5
5
  export const getTimerToolConfig = {
6
6
  title: 'Get current timer',
7
- description: 'Return the caller\'s currently-running stopwatch (or null if no timer is active).',
7
+ description: 'Return the caller\'s currently-running stopwatch (or null if no timer is active). The lane is resolved from this session\'s instance token, so a timer started by `orboto_claim` / `orboto_timer_start` in the same checkout reads back (ORB-2137); `laneFallback: true` means the timer was matched as the account\'s only running one because this instance\'s lane was empty - another instance started it.',
8
8
  inputSchema: z.object({}).shape,
9
9
  annotations: { readOnlyHint: true, idempotentHint: true },
10
10
  };
@@ -29,6 +29,7 @@ export function makeGetTimerHandler(client) {
29
29
  `Elapsed: ${minutes} min (${totalSeconds}s total)`,
30
30
  `Started: ${timer.startedAt}`,
31
31
  isPaused ? `Paused: ${timer.pausedAt}` : null,
32
+ timer.laneFallback ? 'Matched via the single-active-timer fallback - another instance started it.' : null,
32
33
  timer.description ? `Note: ${timer.description}` : null,
33
34
  ].filter((l) => l !== null).join('\n');
34
35
  return {
@@ -44,6 +45,7 @@ export function makeGetTimerHandler(client) {
44
45
  accumulatedSeconds: timer.accumulatedSeconds,
45
46
  totalSeconds,
46
47
  paused: isPaused,
48
+ laneFallback: timer.laneFallback === true,
47
49
  },
48
50
  },
49
51
  };
@@ -192,6 +192,57 @@ describe('orboto_get_ticket', () => {
192
192
  expect(text).toContain('Comments: 3');
193
193
  expect(text).toContain('Checklists: 1/2 done');
194
194
  });
195
+ it('ORB-2169: the card shows the open dependency edges in both directions', async () => {
196
+ stub([
197
+ { json: PROJ },
198
+ { json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-5', title: 'Bug', status: 'TODO', statusName: 'To Do', type: 'bug', priority: 'normal', gitActivityCount: 0, parentTicketId: null } },
199
+ { json: {
200
+ id: 't1', projectId: 'p1', ticketKey: 'ACME-5', title: 'Bug', status: 'TODO', statusName: 'To Do',
201
+ statusCategory: 'todo', type: 'bug', priority: 'normal', commentCount: 0, gitActivityCount: 0,
202
+ blockedByOpenCount: 2,
203
+ blockedByOpen: [
204
+ { ticketKey: 'ACME-2', title: 'Schema first', statusName: 'In Progress', external: false },
205
+ { ticketKey: null, title: null, statusName: null, external: true },
206
+ ],
207
+ blocksOpenCount: 1,
208
+ blocksOpen: [{ ticketKey: 'ACME-9', title: 'Rollout', statusName: 'To Do', external: false }],
209
+ } },
210
+ NO_CHILDREN,
211
+ { json: [] },
212
+ ]);
213
+ const res = await makeGetTicketHandler(client)({ ticketKey: 'ACME-5' });
214
+ const text = res.content[0].text;
215
+ expect(text).toContain('Blocked by: 2 open - [ACME-2] Schema first (In Progress), External dependency (access restricted)');
216
+ expect(text).toContain('Blocks: 1 open - [ACME-9] Rollout (To Do)');
217
+ const sc = res.structuredContent;
218
+ expect(sc.blockedBy).toEqual({
219
+ openCount: 2,
220
+ tickets: [
221
+ { key: 'ACME-2', title: 'Schema first', status: 'In Progress' },
222
+ { key: null, title: null, status: null, external: true },
223
+ ],
224
+ });
225
+ expect(sc.blocks).toEqual({
226
+ openCount: 1,
227
+ tickets: [{ key: 'ACME-9', title: 'Rollout', status: 'To Do' }],
228
+ });
229
+ });
230
+ it('ORB-2169: a ticket with no dependency edge prints no blocker line', async () => {
231
+ stub([
232
+ { json: PROJ },
233
+ { json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-5', title: 'Bug', status: 'TODO', statusName: 'To Do', type: 'bug', priority: 'normal', gitActivityCount: 0, parentTicketId: null } },
234
+ { json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-5', title: 'Bug', status: 'TODO', statusName: 'To Do', statusCategory: 'todo', type: 'bug', priority: 'normal', commentCount: 0, gitActivityCount: 0, blockedByOpenCount: 0, blockedByOpen: [], blocksOpenCount: 0, blocksOpen: [] } },
235
+ NO_CHILDREN,
236
+ { json: [] },
237
+ ]);
238
+ const res = await makeGetTicketHandler(client)({ ticketKey: 'ACME-5' });
239
+ const text = res.content[0].text;
240
+ expect(text).not.toContain('Blocked by');
241
+ expect(text).not.toContain('Blocks');
242
+ const sc = res.structuredContent;
243
+ expect(sc.blockedBy).toEqual({ openCount: 0, tickets: [] });
244
+ expect(sc.blocks).toEqual({ openCount: 0, tickets: [] });
245
+ });
195
246
  it('ORB-1698: the default response for a 30+-comment ticket stays under the 4k budget', async () => {
196
247
  stub([
197
248
  { json: PROJ },
@@ -10,6 +10,17 @@ export async function resolveProjectByKey(client, key) {
10
10
  throw err;
11
11
  }
12
12
  }
13
+ /** ORB-2169 - the "Blocked by" / "Blocks" lines of a ticket decision card. */
14
+ export function formatDependencySummary(label, count, edges) {
15
+ const total = count ?? 0;
16
+ if (total === 0)
17
+ return null;
18
+ const shown = (edges ?? []).map((e) => (e.ticketKey
19
+ ? `[${e.ticketKey}] ${e.title ?? ''}${e.statusName ? ` (${e.statusName})` : ''}`.trim()
20
+ : 'External dependency (access restricted)'));
21
+ const more = total > shown.length ? `, +${total - shown.length} more` : '';
22
+ return `${label}: ${total} open - ${shown.join(', ')}${more}`;
23
+ }
13
24
  /**
14
25
  * ORB-1699 - the ONE agent-facing list-row builder. A list call is a
15
26
  * decision aid ("which of these do I open?"); the default row carries
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
10
10
  import { OrbotoClient } from '../orboto-client.js';
11
- import { resolveProjectByKey, resolveTicketByKey, ticketLine, normalizeName, resolveByName } from './shared.js';
11
+ import { resolveProjectByKey, resolveTicketByKey, ticketLine, normalizeName, resolveByName, mcpProcessInstance, checkoutInstanceToken } from './shared.js';
12
12
  beforeEach(() => { vi.restoreAllMocks(); });
13
13
  afterEach(() => { vi.restoreAllMocks(); });
14
14
  function sequence(responses) {
@@ -171,3 +171,9 @@ describe('ORB-2171 - the stdio proxy shares the checkout token with the CLI and
171
171
  expect(checkoutInstanceToken({}, 'build-host', '/srv/other')).not.toBe('agent-e0b18afc9cec');
172
172
  });
173
173
  });
174
+ describe('mcpProcessInstance', () => {
175
+ it('is the checkout token this process derived at start', () => {
176
+ expect(mcpProcessInstance()).toBe(checkoutInstanceToken());
177
+ expect(mcpProcessInstance().length).toBeGreaterThan(0);
178
+ });
179
+ });
@@ -54,6 +54,7 @@ function ticketStructured(t) {
54
54
  export const createTicketToolConfig = {
55
55
  title: 'Create a ticket',
56
56
  description: 'Create a new ticket in the given project. Creating more than ~3 tickets? Use `orboto_bulk_create_tickets` instead - one call, one compact response. Returns the new ticket\'s key (e.g. "ACME-42") so callers can chain follow-ups. **Read the new key from `structuredContent.createdTicketKey` (ORB-1176) - never from `similarWarnings[].ticketKey`, which are OTHER, possibly-duplicate tickets.** The caller must have `ticket:create` on the project. **Duplicate-detection safety-net (ORB-831):** if `similarWarnings` appears in the response with one or more entries, the ticket WAS created but you should review whether to close it as a duplicate of the listed ticket(s) instead. The warnings are advisory - never blocking - but each entry is a ticket the system thinks the new one overlaps with. Prefer `orboto_check_similar` BEFORE creating when you want a dry-run. **Deferred check under load (ORB-1437):** if `duplicateCheckDeferred: true` appears, the project was under a create burst so the duplicate-check was run in the background instead of inline - `similarWarnings` is then empty because it did NOT run synchronously, which is NOT the same as "no duplicates found". A strong match, if any, is posted as an advisory comment on the new ticket a moment later; check the ticket comments before treating it as new work. **Duplicate-check recall (ORB-1121):** when you search/check-similar first, results rank by term co-occurrence - a long, solution-framed title with rare terms can return 0 hits even when a short, symptom-framed dup sharing one distinctive token exists. Probe with a single distinctive STABLE token (file/component/error-string fragment), keep queries SHORT, and search the SYMPTOM not your fix; a 0-result long query is not "no dup". **Language-mismatch warning (ORB-890):** if `languageWarning` appears, the ticket was written in a language different from the workspace default. Consider rewriting in the expected language so search + duplicate-detection stay consistent. Non-blocking. **Before a mass-create (ORB-989):** call `orboto_whoami` first - its `workspaceLocale` field is the language you should write every ticket in. If the same `languageWarning` repeats, stop and clarify the intended language rather than pushing through the whole batch. **Strict mode (ORB-990):** if the workspace enforces ticket language, a mismatch is rejected (the tool returns a `blocked` result, not a created ticket) - rewrite in the workspace language, or set `allowLanguageMismatch: true` only when the language is genuinely intentional. **Hard duplicate-block (ORB-1471):** some workspaces REFUSE a create whose top similarity match is at/above a configured threshold - the tool returns a `duplicateBlocked` result (NOT a created ticket) listing the matching tickets. Extend or comment on one of those instead. If you have confirmed none of them cover this work, retry with `allowDuplicate: true` AND a `duplicateJustification` explaining why - the justification is persisted as a comment on the new ticket. '
57
+ + '**Pre-flight (ORB-2162):** `dryRun: true` runs the same language, type, label and duplicate checks the write runs and returns their verdicts WITHOUT creating anything - `structuredContent.preflight` carries `ok`, `severity`, `blockedBy` and one verdict per check, each with the exact message the real create would answer with. Use it when a create would otherwise cost a round trip to fix the payload afterwards; re-send the same arguments without `dryRun` once the verdicts are clean. '
57
58
  + '**Parameter notes.** `deliveryMode` (ORB-1608) is the role-aware commit policy that replaced the blanket one-commit-per-ticket rule: implementation/docs expect exactly one linked commit (closing without one is a non-blocking warning); review/admin/epic never expect one - reviews are read-only, admin work carries external evidence, epics derive completion from their children; unset defaults to "epic" when type=epic, else "implementation". `milestone` takes a key ("ORB-M3"), a name, or a UUID and is looked up in the project including closed milestones - unknown or ambiguous is an error, so pass the key/UUID when a name repeats. `labels` and `assigneeEmails` attach ATOMICALLY inside the create (ORB-1416): an unknown label or non-member email rolls the whole create back with a 400, leaving no orphan ticket - there is no separate attach call to retry.',
58
59
  inputSchema: z.object({
59
60
  projectKey: z.string().min(1).describe('Project key (e.g. "ACME").'),
@@ -74,12 +75,26 @@ export const createTicketToolConfig = {
74
75
  allowLanguageMismatch: z.boolean().optional().describe('Override the language block.'),
75
76
  allowDuplicate: z.boolean().optional().describe('Override the duplicate block.'),
76
77
  duplicateJustification: z.string().optional().describe('Why this is not a duplicate.'),
78
+ dryRun: z.boolean().optional().describe('Pre-flight only: return the verdicts, create nothing.'),
77
79
  }).shape,
78
80
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
79
81
  };
80
82
  export function makeCreateTicketHandler(client) {
81
83
  return async (input) => {
82
84
  const project = await resolveProjectByKey(client, input.projectKey);
85
+ if (input.dryRun) {
86
+ const parentTicketId = input.parentTicketKey
87
+ ? (await resolveTicketByKey(client, input.parentTicketKey)).id
88
+ : undefined;
89
+ const preflight = await client.post(`/projects/${project.id}/tickets/preflight`, {
90
+ title: input.title,
91
+ description: input.description ?? null,
92
+ ...(input.type ? { type: input.type } : {}),
93
+ ...(input.labels && input.labels.length > 0 ? { labelNames: input.labels } : {}),
94
+ ...(parentTicketId ? { parentTicketId } : {}),
95
+ });
96
+ return preflightResult(preflight, input.projectKey);
97
+ }
83
98
  const body = {
84
99
  title: input.title,
85
100
  description: input.description ?? null,
@@ -153,6 +168,43 @@ export function makeCreateTicketHandler(client) {
153
168
  };
154
169
  };
155
170
  }
171
+ /**
172
+ * ORB-2162 - render the dry run: one line per verdict plus the next step.
173
+ * Never an error result - a blocking verdict is the answer the caller asked
174
+ * for, not a failed call.
175
+ */
176
+ function preflightResult(preflight, projectKey) {
177
+ const icon = { ok: '✓', warn: '⚠', block: '⛔' };
178
+ const lines = [
179
+ preflight.ok
180
+ ? `✓ Pre-flight clean for ${projectKey} - re-send the same arguments without dryRun to create the ticket.`
181
+ : `⛔ Pre-flight BLOCKED for ${projectKey} (${preflight.blockedBy.join(', ')}) - nothing was created. Fix the payload and run the pre-flight again.`,
182
+ ` ${icon[preflight.verdicts.language.severity]} language: ${preflight.verdicts.language.message}`,
183
+ ` ${icon[preflight.verdicts.type.severity]} type: ${preflight.verdicts.type.message}`,
184
+ ` ${icon[preflight.verdicts.labels.severity]} labels: ${preflight.verdicts.labels.message}`,
185
+ ` ${icon[preflight.verdicts.duplicates.severity]} duplicates: ${preflight.verdicts.duplicates.message}`,
186
+ ];
187
+ for (const c of preflight.verdicts.duplicates.candidates) {
188
+ lines.push(` - [${c.ticketKey ?? c.id.slice(0, 8)}] "${c.title}" (${formatSimilarity(c)})`);
189
+ }
190
+ return {
191
+ content: [{ type: 'text', text: lines.join('\n') }],
192
+ structuredContent: {
193
+ preflight: {
194
+ ok: preflight.ok,
195
+ severity: preflight.severity,
196
+ blockedBy: preflight.blockedBy,
197
+ verdicts: {
198
+ ...preflight.verdicts,
199
+ duplicates: {
200
+ ...preflight.verdicts.duplicates,
201
+ candidates: trimSimilarEntries(preflight.verdicts.duplicates.candidates),
202
+ },
203
+ },
204
+ },
205
+ },
206
+ };
207
+ }
156
208
  /** Append the summaryWarning (ORB-1332) to a move/close result - extra text
157
209
  * line + the structured field - so both the model's prose read and any
158
210
  * structured consumer see it. No-op when the backend didn't warn. */
@@ -354,9 +406,22 @@ export function makeDeleteTicketHandler(client) {
354
406
  };
355
407
  };
356
408
  }
409
+ /** ORB-2168 - the "who did this reach" line of a comment write. */
410
+ function mentionLine(created) {
411
+ const resolved = created.mentions ?? [];
412
+ const unresolved = created.unresolvedMentions ?? [];
413
+ if (resolved.length === 0 && unresolved.length === 0)
414
+ return null;
415
+ const parts = [];
416
+ if (resolved.length > 0)
417
+ parts.push(`notified ${resolved.map((m) => m.fullName ?? m.email).join(', ')}`);
418
+ if (unresolved.length > 0)
419
+ parts.push(`UNRESOLVED ${unresolved.map((m) => `${m.token} (${m.reason})`).join(', ')}`);
420
+ return `Mentions: ${parts.join(' · ')}`;
421
+ }
357
422
  export const commentToolConfig = {
358
423
  title: 'Post a comment',
359
- description: 'Post Markdown. isInternal=true hides it from guests. Attachment drafts are claimed atomically with the comment; never use public ticket uploads for private comments.',
424
+ description: 'Post Markdown. isInternal=true hides it from guests. Attachment drafts are claimed atomically with the comment; never use public ticket uploads for private comments. Mention a person with the markup `@[Full Name](user:<uuid>)` - the UUIDs come from `orboto_list_users`. The response reports `mentions` (tokens that resolved to a user, who is then notified) and `unresolvedMentions` (tokens that named nobody); a mention is never silently dropped.',
360
425
  inputSchema: z.object({
361
426
  ticketKey: z.string().min(3),
362
427
  text: z.string().min(1),
@@ -369,16 +434,22 @@ export function makeCommentHandler(client) {
369
434
  return async ({ ticketKey, text, isInternal, attachmentDraftIds }) => {
370
435
  const ticket = await resolveTicketByKey(client, ticketKey);
371
436
  const created = await client.post(`/tickets/${ticket.id}/comments`, { content: text, isInternal: isInternal ?? false, ...(attachmentDraftIds ? { attachmentDraftIds } : {}) });
437
+ const mentions = mentionLine(created);
372
438
  return {
373
439
  content: [{
374
440
  type: 'text',
375
- text: `Posted comment on [${ticket.ticketKey}]${created.isInternal ? ' (internal)' : ''}.`,
441
+ text: [
442
+ `Posted comment on [${ticket.ticketKey}]${created.isInternal ? ' (internal)' : ''}.`,
443
+ ...(mentions ? [mentions] : []),
444
+ ].join('\n'),
376
445
  }],
377
446
  structuredContent: {
378
447
  ticketKey: ticket.ticketKey,
379
448
  commentId: created.id,
380
449
  isInternal: created.isInternal,
381
450
  createdAt: created.createdAt,
451
+ mentions: created.mentions ?? [],
452
+ unresolvedMentions: created.unresolvedMentions ?? [],
382
453
  },
383
454
  };
384
455
  };
@@ -275,6 +275,38 @@ describe('orboto_create_ticket', () => {
275
275
  expect(createUrl).toContain('allowDuplicate=true');
276
276
  expect(createBody.duplicateJustification).toBe('Different provider - Stripe vs PayPal.');
277
277
  });
278
+ it('dryRun hits the pre-flight route, creates nothing and reports every verdict (ORB-2162)', async () => {
279
+ const calls = stub([
280
+ { json: PROJ },
281
+ {
282
+ json: {
283
+ ok: false,
284
+ severity: 'block',
285
+ blockedBy: ['labels'],
286
+ verdicts: {
287
+ language: { severity: 'ok', code: null, detected: null, expected: 'en', enforced: false, message: 'Language matches the workspace default (en).' },
288
+ type: { severity: 'ok', value: 'bug', allowed: ['epic', 'story', 'task', 'bug'], message: 'Type "bug" is valid.' },
289
+ labels: { severity: 'block', requested: ['ghost'], resolved: [], unknown: ['ghost'], ambiguous: [], available: ['infra'], message: 'Label "ghost" does not exist on this project. Create it first, then reference it by name.' },
290
+ duplicates: { severity: 'warn', threshold: 0, topSimilarity: 0.41, mode: 'tsvector', message: 'Possible duplicates - review the candidates before creating (top match 41%).', candidates: [{ id: 'x1', ticketKey: 'ACME-3', title: 'Timer reads null', statusName: 'To Do', statusColor: null, statusCategory: 'todo', similarity: 0.41, matchMode: 'tsvector' }] },
291
+ },
292
+ },
293
+ },
294
+ ]);
295
+ const res = await makeCreateTicketHandler(client)({
296
+ projectKey: 'ACME', title: 'Timer reads null after a claim', type: 'bug', labels: ['ghost'], dryRun: true,
297
+ });
298
+ expect(calls[1].method).toBe('POST');
299
+ expect(calls[1].url).toBe('https://orboto.example.com/projects/p1/tickets/preflight');
300
+ expect(calls[1].body).toMatchObject({ title: 'Timer reads null after a claim', type: 'bug', labelNames: ['ghost'] });
301
+ const text = res.content[0].text;
302
+ expect(text).toContain('Pre-flight BLOCKED for ACME (labels)');
303
+ expect(text).toContain('does not exist on this project');
304
+ expect(text).toContain('ACME-3');
305
+ expect(res.isError).toBeUndefined();
306
+ const structured = res.structuredContent;
307
+ expect(structured.preflight.ok).toBe(false);
308
+ expect(structured.preflight.blockedBy).toEqual(['labels']);
309
+ });
278
310
  });
279
311
  describe('orboto_update_ticket', () => {
280
312
  it('PATCHes only the supplied fields, leaves others untouched', async () => {
@@ -8,6 +8,16 @@ import { mcpInstanceToken, resolveTicketByKey } from './shared.js';
8
8
  import { validateRulesReceipt } from '../required-rules.js';
9
9
  export { makeWorkNextHandler, workNextToolConfig } from './work-next.js';
10
10
  import { GIT_HEALTH_REASON_TEXT, ResourceClaimShape, } from './work-sessions-shared.js';
11
+ /** ORB-2173 - the phrase for a `bookedBy` value other than `finish` (the
12
+ * ordinary "this call stopped it" path, which stays silent). */
13
+ function describeBookedBy(bookedBy) {
14
+ switch (bookedBy) {
15
+ case 'transition': return 'by the status transition';
16
+ case 'lane_stop': return 'by an earlier timer stop';
17
+ case 'expiry': return 'by the lease expiring';
18
+ default: return null;
19
+ }
20
+ }
11
21
  function parseClaimConflicts(err) {
12
22
  try {
13
23
  return JSON.parse(err.body).claimConflicts;
@@ -328,12 +338,21 @@ export function makeWorkSessionFinishHandler(client) {
328
338
  ...(args.commitSha ? { commitSha: args.commitSha } : {}),
329
339
  ...(args.verification ? { verification: args.verification } : {}),
330
340
  });
341
+ const bookedByPhrase = describeBookedBy(res.bookedBy);
331
342
  const text = res.changed
332
- ? `Session ${args.sessionId} ${res.session.status}. Booked ${res.durationMinutes} min; the ${res.session.role} lease on ${res.session.ticketKey ?? res.session.ticketId} is free.`
333
- : `Session ${args.sessionId} was already ${res.session.status} - nothing to book. (Idempotent finish.)`;
343
+ ? `Session ${args.sessionId} ${res.session.status}. Booked ${res.durationMinutes} min${bookedByPhrase ? ` (${bookedByPhrase})` : ''}; the ${res.session.role} lease on ${res.session.ticketKey ?? res.session.ticketId} is free.`
344
+ : res.durationMinutes > 0
345
+ ? `Session ${args.sessionId} was already ${res.session.status} - ${res.durationMinutes} min already booked${bookedByPhrase ? ` (${bookedByPhrase})` : ''}. (Idempotent finish.)`
346
+ : `Session ${args.sessionId} was already ${res.session.status} - nothing to book. (Idempotent finish.)`;
334
347
  return {
335
348
  content: [{ type: 'text', text }],
336
- structuredContent: { session: res.session, durationMinutes: res.durationMinutes, changed: res.changed },
349
+ structuredContent: {
350
+ session: res.session,
351
+ durationMinutes: res.durationMinutes,
352
+ timeEntryId: res.timeEntryId,
353
+ bookedBy: res.bookedBy,
354
+ changed: res.changed,
355
+ },
337
356
  };
338
357
  };
339
358
  }
@@ -368,10 +387,13 @@ export function makeWorkFinishHandler(client) {
368
387
  ...(args.targetCategory ? { targetCategory: args.targetCategory } : {}),
369
388
  ...(args.note ? { note: args.note } : {}),
370
389
  });
390
+ const bookedByPhrase = describeBookedBy(res.bookedBy);
371
391
  const lines = [
372
392
  res.changed
373
- ? `Session ${args.sessionId} ${res.session.status}. Booked ${res.durationMinutes} min; the ${res.session.role} lease on ${res.session.ticketKey ?? res.session.ticketId} is free.`
374
- : `Session ${args.sessionId} was already ${res.session.status} - nothing to book. (Idempotent finish.)`,
393
+ ? `Session ${args.sessionId} ${res.session.status}. Booked ${res.durationMinutes} min${bookedByPhrase ? ` (${bookedByPhrase})` : ''}; the ${res.session.role} lease on ${res.session.ticketKey ?? res.session.ticketId} is free.`
394
+ : res.durationMinutes > 0
395
+ ? `Session ${args.sessionId} was already ${res.session.status} - ${res.durationMinutes} min already booked${bookedByPhrase ? ` (${bookedByPhrase})` : ''}. (Idempotent finish.)`
396
+ : `Session ${args.sessionId} was already ${res.session.status} - nothing to book. (Idempotent finish.)`,
375
397
  ];
376
398
  if (res.session.commitSha) {
377
399
  lines.push(`Commit ${res.session.commitSha}${res.session.commitVerified ? ' (verified by git ingestion)' : ' (attested - pending git verification)'}.`);
@@ -387,6 +409,8 @@ export function makeWorkFinishHandler(client) {
387
409
  structuredContent: {
388
410
  session: res.session,
389
411
  durationMinutes: res.durationMinutes,
412
+ timeEntryId: res.timeEntryId,
413
+ bookedBy: res.bookedBy,
390
414
  changed: res.changed,
391
415
  ticketTransitioned: res.ticketTransitioned,
392
416
  ticketStatusCategory: res.ticketStatusCategory,
@@ -131,6 +131,36 @@ describe('orboto_work_session_finish', () => {
131
131
  expect(res.isError).toBeUndefined();
132
132
  expect(res.content[0].text).toContain('Idempotent finish');
133
133
  });
134
+ it('ORB-2173 - reports the minutes a status transition already booked instead of zero', async () => {
135
+ stub([{
136
+ json: {
137
+ session: { ...SESSION, status: 'active', activeTimerId: null },
138
+ durationMinutes: 106,
139
+ timeEntryId: 'te1',
140
+ bookedBy: 'transition',
141
+ changed: true,
142
+ },
143
+ }]);
144
+ const res = await makeWorkSessionFinishHandler(client)({ sessionId: 'ws1' });
145
+ const text = res.content[0].text;
146
+ expect(text).toContain('Booked 106 min (by the status transition)');
147
+ expect(res.structuredContent.bookedBy).toBe('transition');
148
+ });
149
+ it('ORB-2173 - an idempotent re-finish still reports the already-booked minutes and source', async () => {
150
+ stub([{
151
+ json: {
152
+ session: { ...SESSION, status: 'finished' },
153
+ durationMinutes: 42,
154
+ timeEntryId: 'te2',
155
+ bookedBy: 'lane_stop',
156
+ changed: false,
157
+ },
158
+ }]);
159
+ const res = await makeWorkSessionFinishHandler(client)({ sessionId: 'ws1' });
160
+ const text = res.content[0].text;
161
+ expect(text).toContain('42 min already booked (by an earlier timer stop)');
162
+ expect(text).toContain('Idempotent finish');
163
+ });
134
164
  });
135
165
  describe('ORB-1612 - orboto_work_finish', () => {
136
166
  it('reports the ticket transition and the completion note', async () => {
@@ -196,6 +226,25 @@ describe('ORB-1612 - orboto_work_finish', () => {
196
226
  expect(text).toContain('Idempotent finish');
197
227
  expect(text).toContain('No completion note posted');
198
228
  });
229
+ it('ORB-2173 - reports the minutes a status transition already booked instead of zero', async () => {
230
+ stub([
231
+ {
232
+ json: {
233
+ session: { ...SESSION, status: 'active', activeTimerId: null },
234
+ durationMinutes: 106,
235
+ timeEntryId: 'te1',
236
+ bookedBy: 'transition',
237
+ changed: true,
238
+ ticketTransitioned: false,
239
+ ticketStatusCategory: 'in_review',
240
+ noteCommented: true,
241
+ },
242
+ },
243
+ ]);
244
+ const res = await makeWorkFinishHandler(client)({ sessionId: 'ws1' });
245
+ expect(res.content[0].text).toContain('Booked 106 min (by the status transition)');
246
+ expect(res.structuredContent.bookedBy).toBe('transition');
247
+ });
199
248
  it('surfaces a deliveryModeWarning without failing the call', async () => {
200
249
  stub([
201
250
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orboto/mcp",
3
- "version": "0.200.1",
3
+ "version": "0.202.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",