@orboto/mcp 0.201.0 → 0.203.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.
@@ -9,7 +9,7 @@ export const AGENT_HEAD_HEADING = 'How to work in orboto';
9
9
  export const AGENT_HEAD_LINES = [
10
10
  'orboto is your memory and your work contract, not your context - what is not on the ticket or in a doc is gone after the next context switch.',
11
11
  'A ticket is a handover to a stranger: goal, verified current state, target, acceptance criteria.',
12
- 'You are not alone - the lease and the claims say who works on what, your scope says what you answer for, the inbox is the channel: ack what you handled, dismiss the rest with a reason.',
12
+ 'You are not alone - the lease and the claims say who works on what, your scope says what you answer for, the inbox is the channel: ack what you handled and that ack closes the request unless somebody claimed it, dismiss the rest with a reason, and take orboto_message_work only for work that outlives this turn.',
13
13
  'You act with a person\'s rights and mandate: an assignment is an instruction, anything irreversible or outward-facing needs the operator\'s clear word.',
14
14
  'Evidence over assertion - check code, docs and the live state, and write "not verified: X" instead of a guess.',
15
15
  'Start in this order: whoami, session start with your scope, the project primer, then the inbox - the rules are a set, this is the sequence.',
@@ -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());
@@ -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
+ });
@@ -40,7 +40,10 @@ export function makeListUsersHandler(client) {
40
40
  tags.push('external');
41
41
  if (!u.isActive)
42
42
  tags.push('disabled');
43
- return `- ${u.fullName} <${u.email}>${tags.length ? ` [${tags.join(', ')}]` : ''}`;
43
+ if (u.provisionedByPlatform)
44
+ tags.push('provisioned by the platform');
45
+ const purpose = u.provisionedByPlatform && u.purpose ? ` - ${u.purpose}` : '';
46
+ return `- ${u.fullName} <${u.email}>${tags.length ? ` [${tags.join(', ')}]` : ''}${purpose}`;
44
47
  }).join('\n');
45
48
  return {
46
49
  content: [{ type: 'text', text }],
@@ -54,6 +57,8 @@ export function makeListUsersHandler(client) {
54
57
  isExternal: u.isExternal,
55
58
  isBot: u.isBot,
56
59
  lastSeenAt: u.lastSeenAt ?? null,
60
+ provisionedByPlatform: u.provisionedByPlatform ?? false,
61
+ purpose: u.purpose ?? null,
57
62
  })),
58
63
  },
59
64
  };
@@ -2,11 +2,14 @@ import { z } from 'zod';
2
2
  import { mcpInstanceToken } from './shared.js';
3
3
  export const agentMessageWorkToolConfig = {
4
4
  title: 'Inspect or explicitly handle message work',
5
- description: 'List, inspect or mutate durable message work (mutation schema: orboto_api_search). ACK is a separate read receipt and finishes nothing.',
5
+ description: 'List, inspect or mutate durable message work (mutation schema: orboto_api_search). Only request/error mail carries work; an ack completes it unless somebody claimed it.',
6
6
  inputSchema: z.object({ messageId: z.string().uuid().optional(), mutation: z.record(z.unknown()).optional(),
7
7
  cursor: z.string().optional(), limit: z.number().int().min(1).max(100).default(25), openOnly: z.boolean().default(true) }).shape,
8
8
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
9
9
  };
10
+ /** ORB-2190 - the resolved states, said once in the tool output rather than in the manifest. */
11
+ export const WORK_STATE_NOTE = 'carriesWork false = info/complete/digest mail, no work state. An open list holds requests and errors only, oldest first; '
12
+ + 'presence unclaimed | active | stalled | expired, and completed once you finish it, an ack closes it, a dismissal closes it or its ticket resolves it.';
10
13
  export function makeAgentMessageWorkHandler(client) {
11
14
  return async (args, extra) => {
12
15
  if (args.mutation && !args.messageId)
@@ -17,6 +20,6 @@ export function makeAgentMessageWorkHandler(client) {
17
20
  const result = args.mutation
18
21
  ? await client.post(`/v1/agent/messages/${args.messageId}/work`, { ...args.mutation, instanceToken: mcpInstanceToken(undefined, extra) })
19
22
  : await client.get(`${args.messageId ? `/v1/agent/messages/${args.messageId}` : '/v1/agent/message-work'}?${q}`, { instanceToken: mcpInstanceToken(undefined, extra) });
20
- return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
23
+ return { content: [{ type: 'text', text: `${JSON.stringify(result)}\n${WORK_STATE_NOTE}` }], structuredContent: result };
21
24
  };
22
25
  }
@@ -13,7 +13,7 @@ import { mcpInstanceToken } from './shared.js';
13
13
  const PartySchema = z.object({ userId: z.string(), email: z.string(), sessionId: z.string().nullable(), role: z.string().nullable(), label: z.string() });
14
14
  export const agentMessagesToolConfig = {
15
15
  title: 'Agent inbox and message work',
16
- description: 'Your inbox as THIS session sees it: mail to this session, account mail inside your declared scope, broadcasts; sibling-session mail and own sends stay out, all:true shows the whole account (ORB-2136). messageWork carries durable work ownership - claim before executing. ackIds is a plain read receipt: no claim needed, finishes nothing. dismiss is the third answer: not_mine hides it here and tells the sender, obsolete/duplicate close it for the account (note or duplicateOf required). Never leave mail you read and judged. Reply via orboto_agent_notify with threadId (toSessionRef = from.sessionId reaches that instance).',
16
+ description: 'Your inbox as THIS session sees it: mail to this session, account mail inside your declared scope, broadcasts; sibling-session mail and own sends stay out, all:true shows the whole account (ORB-2136). messageWork carries durable work ownership - claim before executing. ackIds acks: it completes an unclaimed request, never claimed work. dismiss is the third answer: not_mine hides it here and tells the sender, obsolete/duplicate close it for the account (note or duplicateOf required). Never leave mail you read and judged. Reply via orboto_agent_notify with threadId (toSessionRef = from.sessionId reaches that instance).',
17
17
  inputSchema: z.object({
18
18
  messageWork: z.record(z.unknown()).optional()
19
19
  .describe('Work envelope {messageId, mutation, cursor, limit, openOnly}; schema: orboto_api_search.'),
@@ -45,6 +45,12 @@ 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(),
52
+ /** ORB-2189 - the answering instance holds this session's live wake channel, so a restart request can reach it. */
53
+ restartable: z.boolean(),
48
54
  lastSeenAt: z.string(),
49
55
  createdAt: z.string(),
50
56
  });
@@ -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
+ });
@@ -8,16 +8,23 @@
8
8
  * download mechanism. `OrbotoClient.postBinary` / `getBinary` fetch the bytes.
9
9
  */
10
10
  import { z } from 'zod';
11
+ /**
12
+ * ORB-1504 - an operator passphrase is deliberately NOT an MCP input: it
13
+ * would land in the conversation transcript and the tool-call audit. The
14
+ * passphrase paths are REST and `orboto backup --passphrase-stdin`; an agent
15
+ * that must drive one uses the escape hatch `orboto_api_call`.
16
+ */
17
+ const seal = (run) => run.sealed ? ` [encrypted, ${run.sealCustody ?? 'platform'} key${run.sealKeyFingerprint ? ` ${run.sealKeyFingerprint}` : ''}]` : '';
11
18
  const mb = (n) => `${Math.round((n / 1024 / 1024) * 10) / 10} MB`;
12
19
  export const createFullBackupToolConfig = {
13
20
  title: 'Create + download a full workspace backup',
14
- description: 'Create an on-demand FULL workspace backup (database + storage) and return the ZIP as a base64 application/zip resource attachment - create, wait and download in one call (the server runs the export as a background run and this tool polls it; large workspaces can take minutes). Rows and file references share one snapshot; missing or incomplete files fail the run, never return a partial ZIP. Requires admin:backup:export. For the scheduled named jobs use orboto_trigger_backup instead.',
21
+ description: 'Create an on-demand FULL workspace backup (database + storage) and return it as a base64 resource attachment - one call; a background run this tool polls, minutes on a large workspace. Rows and file references share one snapshot; a missing file fails the run, never a partial archive. With backup encryption on the attachment is the sealed archive, not a ZIP. Requires admin:backup:export; scheduled jobs use orboto_trigger_backup.',
15
22
  inputSchema: z.object({}).shape,
16
23
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
17
24
  };
18
25
  export function makeCreateFullBackupHandler(client) {
19
26
  return async () => {
20
- const { runId } = await client.post('/admin/backup/full', {});
27
+ const { runId, sealed } = await client.post('/admin/backup/full', {});
21
28
  const t0 = Date.now();
22
29
  for (;;) {
23
30
  await new Promise((r) => setTimeout(r, 3000));
@@ -31,12 +38,15 @@ export function makeCreateFullBackupHandler(client) {
31
38
  }
32
39
  const { bytes, contentType } = await client.getBinary(`/admin/backup/runs/${runId}/download`);
33
40
  const base64 = Buffer.from(bytes).toString('base64');
41
+ const note = sealed
42
+ ? ' The archive is encrypted with the instance key - it only restores where that key exists.'
43
+ : '';
34
44
  return {
35
45
  content: [
36
- { type: 'resource', resource: { uri: 'orboto://backup/full.zip', mimeType: contentType, blob: base64 } },
37
- { type: 'text', text: `Created full workspace backup (${mb(bytes.byteLength)}), run ${runId}.` },
46
+ { type: 'resource', resource: { uri: sealed ? 'orboto://backup/full.zip.orbenc' : 'orboto://backup/full.zip', mimeType: contentType, blob: base64 } },
47
+ { type: 'text', text: `Created full workspace backup (${mb(bytes.byteLength)}), run ${runId}.${note}` },
38
48
  ],
39
- structuredContent: { sizeBytes: bytes.byteLength, contentType, runId },
49
+ structuredContent: { sizeBytes: bytes.byteLength, contentType, runId, sealed },
40
50
  };
41
51
  };
42
52
  }
@@ -50,7 +60,7 @@ export function makeListBackupsHandler(client) {
50
60
  return async () => {
51
61
  const page = await client.get('/admin/backup/runs');
52
62
  const items = page.items ?? [];
53
- const lines = items.map((r) => `- ${r.id} - ${r.status}${r.fileSizeBytes ? ` (${mb(r.fileSizeBytes)})` : ''} - ${r.completedAt ?? r.startedAt}`);
63
+ const lines = items.map((r) => `- ${r.id} - ${r.status}${r.fileSizeBytes ? ` (${mb(r.fileSizeBytes)})` : ''} - ${r.completedAt ?? r.startedAt}${seal(r)}`);
54
64
  return {
55
65
  content: [{ type: 'text', text: items.length ? `Backup runs:\n${lines.join('\n')}` : 'No backup runs yet.' }],
56
66
  structuredContent: { runs: items },
@@ -59,7 +69,7 @@ export function makeListBackupsHandler(client) {
59
69
  }
60
70
  export const downloadBackupToolConfig = {
61
71
  title: 'Download a stored backup run',
62
- description: 'Download a stored backup run\'s ZIP by run id (from orboto_list_backups - e.g. the nightly job\'s run) as a base64 application/zip resource attachment. Requires admin:backup:read.',
72
+ description: 'Download a stored backup run by run id (from orboto_list_backups) as a base64 resource attachment; an encrypted run downloads sealed and needs its key to restore. Requires admin:backup:read.',
63
73
  inputSchema: z.object({
64
74
  runId: z.string().uuid().describe('Backup run id from orboto_list_backups.'),
65
75
  }).shape,
@@ -71,7 +81,7 @@ export function makeDownloadBackupHandler(client) {
71
81
  const base64 = Buffer.from(bytes).toString('base64');
72
82
  return {
73
83
  content: [
74
- { type: 'resource', resource: { uri: `orboto://backup/run-${runId}.zip`, mimeType: contentType, blob: base64 } },
84
+ { type: 'resource', resource: { uri: `orboto://backup/run-${runId}${contentType.includes('sealed') ? '.zip.orbenc' : '.zip'}`, mimeType: contentType, blob: base64 } },
75
85
  { type: 'text', text: `Downloaded backup run ${runId.slice(0, 8)} (${mb(bytes.byteLength)}).` },
76
86
  ],
77
87
  structuredContent: { runId, sizeBytes: bytes.byteLength, contentType },
@@ -35,7 +35,7 @@ export const bulkCreateTicketsToolConfig = {
35
35
  failed: z.array(z.object({ index: z.number().int(), title: z.string(), error: z.string() })),
36
36
  duplicateFlags: z.array(z.object({
37
37
  key: z.string().describe('The new, flagged ticket.'),
38
- matches: z.array(z.object({ ticketKey: z.string().nullable(), similarity: z.number() })),
38
+ matches: z.array(z.object({ ticketKey: z.string().nullable(), similarity: z.number().nullable() })),
39
39
  })),
40
40
  languageWarnings: z.number().int().describe('Drafts with a language warning.'),
41
41
  }).shape,
@@ -65,6 +65,7 @@ export function makeBulkCreateTicketsHandler(client) {
65
65
  const created = [];
66
66
  const failed = [];
67
67
  const duplicateFlags = [];
68
+ let degraded = 0;
68
69
  let languageWarnings = 0;
69
70
  for (let i = 0; i < input.tickets.length; i++) {
70
71
  const draft = input.tickets[i];
@@ -91,13 +92,15 @@ export function makeBulkCreateTicketsHandler(client) {
91
92
  created.push(res.ticketKey ?? res.id);
92
93
  if (res.languageWarning)
93
94
  languageWarnings++;
95
+ if (res.duplicateCheckDegraded)
96
+ degraded++;
94
97
  const warnings = res.similarWarnings ?? [];
95
98
  if (warnings.length > 0) {
96
99
  duplicateFlags.push({
97
100
  key: res.ticketKey ?? res.id,
98
101
  matches: warnings.slice(0, 3).map((w) => ({
99
102
  ticketKey: w.ticketKey,
100
- similarity: Math.round(w.similarity * 100) / 100,
103
+ similarity: w.matchMode === 'embedding' ? Math.round(w.similarity * 100) / 100 : null,
101
104
  })),
102
105
  });
103
106
  }
@@ -115,7 +118,7 @@ export function makeBulkCreateTicketsHandler(client) {
115
118
  if (created.length > 0)
116
119
  lines.push(`Created: ${created.join(', ')}`);
117
120
  for (const f of duplicateFlags) {
118
- lines.push(`⚠ ${f.key} may duplicate ${f.matches.map((m) => `${m.ticketKey ?? '?'} (${m.similarity})`).join(', ')} - review before treating as new work.`);
121
+ lines.push(`⚠ ${f.key} may duplicate ${f.matches.map((m) => `${m.ticketKey ?? '?'} (${m.similarity === null ? 'text match' : m.similarity})`).join(', ')} - review before treating as new work.`);
119
122
  }
120
123
  for (const f of failed) {
121
124
  lines.push(`✗ draft ${f.index} "${f.title}": ${f.error}`);
@@ -123,6 +126,9 @@ export function makeBulkCreateTicketsHandler(client) {
123
126
  if (languageWarnings > 0) {
124
127
  lines.push(`⚠ ${languageWarnings} draft(s) in a non-workspace language - consider rewriting for search/duplicate consistency.`);
125
128
  }
129
+ if (degraded > 0) {
130
+ lines.push(`ℹ ${degraded} draft(s) ran a degraded duplicate-check - the block is on but only full-text candidates were found, whose rank is relative, so nothing was compared with the threshold.`);
131
+ }
126
132
  return {
127
133
  content: [{ type: 'text', text: lines.join('\n') }],
128
134
  structuredContent: { created, failed, duplicateFlags, languageWarnings },
@@ -6,7 +6,7 @@ import { trimSimilarEntries } from './similar-projection.js';
6
6
  import { resolveProjectByKey, resolveTicketByKey } from './shared.js';
7
7
  export const checkSimilarToolConfig = {
8
8
  title: 'Check for similar tickets before creating',
9
- description: 'Run the duplicate-detection pipeline (tsvector + AI-embedding rerank when configured) against a proposed title + description, without creating anything. Returns up to `limit` candidates ranked by similarity. Use this BEFORE `orboto_create_ticket` when you want to confirm a feature is not already tracked - if a high-similarity candidate exists, prefer to comment on / claim / extend it instead of opening a new ticket. Empty result = safe to create. `orboto_create_ticket` runs the same check after the fact and surfaces `similarWarnings` in its response, so this tool is optional but cheaper than a create-then-close round trip. An empty result from a LONG, solution-framed title is weak evidence - detection ranks by term co-occurrence, so also probe with a single distinctive STABLE token (file/component/error-string fragment) and the SYMPTOM wording, not just your intended fix.',
9
+ description: 'Run the duplicate-detection pipeline (tsvector + AI-embedding rerank when configured) against a proposed title + description, without creating anything. Returns up to `limit` candidates ranked by similarity. Use this BEFORE `orboto_create_ticket` when you want to confirm a feature is not already tracked - if a high-similarity candidate exists, prefer to comment on / claim / extend it instead of opening a new ticket. Empty result = safe to create. `orboto_create_ticket` runs the same check after the fact and surfaces `similarWarnings` in its response, so this tool is optional but cheaper than a create-then-close round trip. Only an AI-embedding candidate carries an absolute similarity; a full-text candidate prints as "text match" without a percentage (its rank is relative, so the best text match always scores 1.000) and can never trigger the hard duplicate-block, and neither can a match against a CLOSED ticket (ORB-2185). An empty result from a LONG, solution-framed title is weak evidence - detection ranks by term co-occurrence, so also probe with a single distinctive STABLE token (file/component/error-string fragment) and the SYMPTOM wording, not just your intended fix.',
10
10
  inputSchema: z.object({
11
11
  projectKey: z.string().min(1).describe('Project key (e.g. "ACME").'),
12
12
  title: z.string().min(1).describe('Proposed ticket title.'),
@@ -35,12 +35,13 @@ export function makeCheckSimilarHandler(client) {
35
35
  const result = await client.get(`/projects/${project.id}/tickets/similar?${qs.toString()}`);
36
36
  const realDuplicates = result.candidates.filter((c) => !c.relation);
37
37
  const related = result.candidates.filter((c) => c.relation);
38
+ const strong = realDuplicates.find((c) => c.matchMode === 'embedding' && c.similarity >= 0.9);
38
39
  const recommendation = realDuplicates.length === 0
39
40
  ? (related.length === 0
40
41
  ? 'No similar tickets found - safe to create.'
41
42
  : 'Only related context found (parent/sibling/epic) - safe to create; link them instead of treating as duplicates.')
42
- : (realDuplicates[0].similarity >= 0.9)
43
- ? `HIGH-SIMILARITY MATCH FOUND - review [${realDuplicates[0].ticketKey ?? realDuplicates[0].id.slice(0, 8)}] "${realDuplicates[0].title}" before creating; this may already be tracked.`
43
+ : strong
44
+ ? `HIGH-SIMILARITY MATCH FOUND - review [${strong.ticketKey ?? strong.id.slice(0, 8)}] "${strong.title}" before creating; this may already be tracked.`
44
45
  : 'Possible related tickets - review the list and decide whether the new ticket adds distinct scope.';
45
46
  const text = result.candidates.length === 0
46
47
  ? `${recommendation} (match mode: ${result.mode})`
@@ -48,11 +49,11 @@ export function makeCheckSimilarHandler(client) {
48
49
  recommendation,
49
50
  `Found ${result.candidates.length} candidate(s) via ${result.mode === 'embedding' ? 'AI embedding rerank' : 'tsvector search'}:`,
50
51
  ...result.candidates.map((c) => {
51
- const pct = `${Math.round(c.similarity * 100)}%`;
52
+ const score = c.matchMode === 'embedding' ? `${Math.round(c.similarity * 100)}% AI match` : 'text match';
52
53
  const status = c.statusName ? ` [${c.statusName}]` : '';
53
54
  const key = c.ticketKey ?? c.id.slice(0, 8);
54
55
  const rel = c.relation ? ` (related: ${c.relation})` : '';
55
- return ` - ${key}${status} (${pct} ${c.matchMode})${rel}: ${c.title}`;
56
+ return ` - ${key}${status} (${score})${rel}: ${c.title}`;
56
57
  }),
57
58
  ].join('\n');
58
59
  return {
@@ -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(),
@@ -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
+ });
@@ -2,11 +2,13 @@
2
2
  * ORB-1693 - the agent-facing projection of a duplicate-warning entry.
3
3
  */
4
4
  export function trimSimilarEntry(w) {
5
+ const matchMode = w.matchMode ?? 'embedding';
5
6
  return {
6
7
  ticketKey: w.ticketKey ?? w.id.slice(0, 8),
7
8
  title: w.title,
8
9
  statusCategory: w.statusCategory ?? null,
9
- similarity: Math.round(w.similarity * 100) / 100,
10
+ similarity: matchMode === 'embedding' ? Math.round(w.similarity * 100) / 100 : null,
11
+ matchMode,
10
12
  relation: w.relation ?? null,
11
13
  };
12
14
  }
@@ -53,7 +53,7 @@ function ticketStructured(t) {
53
53
  }
54
54
  export const createTicketToolConfig = {
55
55
  title: 'Create a ticket',
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. '
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, ORB-2185):** a workspace can REFUSE a create - the tool returns `duplicateBlocked` (no ticket) with the candidates. ONLY an AI-embedding match at/above the threshold against an OPEN ticket blocks; a text match and a closed match stay warnings, and a text match is printed WITHOUT a percentage because a text rank is relative, not a similarity. Extend one of the candidates, or retry with `allowDuplicate: true` AND a `duplicateJustification` (saved as a comment). `duplicateCheckDegraded: true` = the block was on but only text candidates existed, so nothing could be compared and the create went through unchecked. '
57
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. '
58
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.',
59
59
  inputSchema: z.object({
@@ -142,6 +142,7 @@ export function makeCreateTicketHandler(client) {
142
142
  const warnings = created.similarWarnings ?? [];
143
143
  const langWarning = created.languageWarning;
144
144
  const deferred = created.duplicateCheckDeferred === true;
145
+ const degraded = created.duplicateCheckDegraded === true;
145
146
  const baseText = ticketSummaryText('Created', created);
146
147
  const parts = [baseText];
147
148
  if (deferred) {
@@ -149,6 +150,11 @@ export function makeCreateTicketHandler(client) {
149
150
  + `An empty duplicate list here does NOT mean "no duplicates". If a strong match is found, an advisory comment `
150
151
  + `will be posted on [${created.ticketKey}] shortly; review it before treating this as new work.`);
151
152
  }
153
+ if (degraded) {
154
+ parts.push(`\nℹ Duplicate-check degraded - the workspace blocks duplicates, but this check found only full-text candidates. `
155
+ + `A text rank is relative (the best match always scores 1.000), so nothing could be compared with the threshold and the create was allowed. `
156
+ + `Read the candidates below yourself; if embeddings are missing workspace-wide, tell the operator (orboto_embedding_status).`);
157
+ }
152
158
  if (warnings.length > 0) {
153
159
  parts.push(`\n⚠ Potential duplicates found - review before treating this as new work:\n${warnings.map((w) => ` - [${w.ticketKey ?? w.id.slice(0, 8)}] "${w.title}" (${formatSimilarity(w)})`).join('\n')}\n If one of these covers the work, close [${created.ticketKey}] as a duplicate via orboto_close_ticket.`);
154
160
  }
@@ -163,6 +169,7 @@ export function makeCreateTicketHandler(client) {
163
169
  createdTicketKey: created.ticketKey,
164
170
  similarWarnings: trimSimilarEntries(warnings),
165
171
  ...(deferred ? { duplicateCheckDeferred: true } : {}),
172
+ ...(degraded ? { duplicateCheckDegraded: true } : {}),
166
173
  ...(langWarning ? { languageWarning: langWarning } : {}),
167
174
  },
168
175
  };
@@ -278,7 +285,10 @@ function duplicateBlockResult(err) {
278
285
  return null;
279
286
  const candidates = parsed.similarWarnings;
280
287
  const lines = candidates.map((w) => ` - [${w.ticketKey ?? w.id.slice(0, 8)}] "${w.title}" (${formatSimilarity(w)})`);
281
- const text = `⛔ Ticket create BLOCKED - this looks like a duplicate (top match ${Math.round((parsed.topSimilarity ?? 0) * 100)}%, block threshold ${Math.round((parsed.threshold ?? 0) * 100)}%).\n` +
288
+ const decidedBy = parsed.decidingTicketKey
289
+ ? `${parsed.decidingTicketKey}, AI embedding match`
290
+ : 'an AI embedding match';
291
+ const text = `⛔ Ticket create BLOCKED - this looks like a duplicate (top match ${Math.round((parsed.topSimilarity ?? 0) * 100)}%, block threshold ${Math.round((parsed.threshold ?? 0) * 100)}%, decided by ${decidedBy} on an OPEN ticket).\n` +
282
292
  `Existing tickets it overlaps with:\n${lines.join('\n')}\n` +
283
293
  `Extend or comment on one of these instead. If you have confirmed NONE of them cover this work, retry orboto_create_ticket with allowDuplicate: true AND a duplicateJustification explaining why it is not a duplicate (it is saved as a comment on the new ticket).`;
284
294
  return {
@@ -287,14 +297,17 @@ function duplicateBlockResult(err) {
287
297
  duplicateBlocked: true,
288
298
  threshold: parsed.threshold ?? null,
289
299
  topSimilarity: parsed.topSimilarity ?? null,
300
+ decidingMode: parsed.decidingMode ?? 'embedding',
301
+ decidingTicketKey: parsed.decidingTicketKey ?? null,
290
302
  similarWarnings: trimSimilarEntries(candidates),
291
303
  },
292
304
  isError: true,
293
305
  };
294
306
  }
307
+ /** ORB-2185 - a full-text rank is relative, so a text match is never a percentage. */
295
308
  function formatSimilarity(w) {
296
- const pct = `${Math.round(w.similarity * 100)}% ${w.matchMode === 'embedding' ? 'AI match' : 'text match'}`;
297
- return w.statusName ? `${w.statusName}, ${pct}` : pct;
309
+ const score = w.matchMode === 'embedding' ? `${Math.round(w.similarity * 100)}% AI match` : 'text match';
310
+ return w.statusName ? `${w.statusName}, ${score}` : score;
298
311
  }
299
312
  export const updateTicketToolConfig = {
300
313
  title: 'Update a ticket',
@@ -116,17 +116,33 @@ describe('orboto_create_ticket', () => {
116
116
  expect(text).toContain('ACME-42');
117
117
  expect(text).toContain('91% AI match');
118
118
  expect(text).toContain('ACME-13');
119
+ expect(text).toContain('Done, text match');
120
+ expect(text).not.toContain('72%');
119
121
  const sc = res.structuredContent;
120
122
  expect(sc.similarWarnings).toHaveLength(2);
121
123
  expect(sc.createdTicketKey).toBe('ACME-99');
122
124
  expect(sc.similarWarnings.map((w) => w.ticketKey)).not.toContain(sc.createdTicketKey);
123
125
  for (const w of sc.similarWarnings) {
124
- expect(Object.keys(w).sort()).toEqual(['relation', 'similarity', 'statusCategory', 'ticketKey', 'title']);
126
+ expect(Object.keys(w).sort()).toEqual(['matchMode', 'relation', 'similarity', 'statusCategory', 'ticketKey', 'title']);
125
127
  }
126
128
  expect(sc.similarWarnings[0]).toEqual({
127
129
  ticketKey: 'ACME-42', title: 'Authentication breaks for SAML',
128
- statusCategory: 'in_progress', similarity: 0.91, relation: null,
130
+ statusCategory: 'in_progress', similarity: 0.91, matchMode: 'embedding', relation: null,
129
131
  });
132
+ expect(sc.similarWarnings[1]).toEqual({
133
+ ticketKey: 'ACME-13', title: 'Auth flow regressed',
134
+ statusCategory: 'done', similarity: null, matchMode: 'tsvector', relation: null,
135
+ });
136
+ });
137
+ it('a degraded duplicate-check is surfaced on the created ticket (ORB-2185)', async () => {
138
+ stub([
139
+ { json: PROJ },
140
+ { json: { ...TICKET, ticketKey: 'ACME-101', duplicateCheckDegraded: true, similarWarnings: [] } },
141
+ ]);
142
+ const res = await makeCreateTicketHandler(client)({ projectKey: 'ACME', title: 'degraded check' });
143
+ const text = res.content[0].text;
144
+ expect(text).toContain('Duplicate-check degraded');
145
+ expect(res.structuredContent.duplicateCheckDegraded).toBe(true);
130
146
  });
131
147
  it('returns no warning block when similarWarnings is empty', async () => {
132
148
  stub([
@@ -234,8 +250,10 @@ describe('orboto_create_ticket', () => {
234
250
  errorKey: 'errors.tickets.duplicate_blocked',
235
251
  threshold: 0.9,
236
252
  topSimilarity: 0.98,
253
+ decidingMode: 'embedding',
254
+ decidingTicketKey: 'ACME-7',
237
255
  similarWarnings: [
238
- { id: 't1', ticketKey: 'ACME-7', title: 'Webhook signature mismatch', statusName: 'To Do', statusColor: null, statusCategory: 'todo', similarity: 0.98, matchMode: 'tsvector' },
256
+ { id: 't1', ticketKey: 'ACME-7', title: 'Webhook signature mismatch', statusName: 'To Do', statusColor: null, statusCategory: 'todo', similarity: 0.98, matchMode: 'embedding' },
239
257
  ],
240
258
  });
241
259
  vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
@@ -251,8 +269,11 @@ describe('orboto_create_ticket', () => {
251
269
  expect(text).toContain('BLOCKED');
252
270
  expect(text).toContain('ACME-7');
253
271
  expect(text).toContain('allowDuplicate: true');
272
+ expect(text).toContain('decided by ACME-7, AI embedding match on an OPEN ticket');
254
273
  const sc = res.structuredContent;
255
274
  expect(sc.duplicateBlocked).toBe(true);
275
+ expect(sc.decidingMode).toBe('embedding');
276
+ expect(sc.decidingTicketKey).toBe('ACME-7');
256
277
  expect(sc.similarWarnings).toHaveLength(1);
257
278
  });
258
279
  it('passes allowDuplicate as a query param and the justification in the body when overriding (ORB-1471)', async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orboto/mcp",
3
- "version": "0.201.0",
3
+ "version": "0.203.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",