@orboto/mcp 0.227.0 → 0.229.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.
Files changed (38) hide show
  1. package/dist/index.js +13 -2
  2. package/dist/manifest-size.test.js +3 -0
  3. package/dist/project-scope.js +15 -1
  4. package/dist/project-scope.test.js +9 -9
  5. package/dist/response-budget.js +1 -1
  6. package/dist/server.js +5 -0
  7. package/dist/session-origin.js +23 -3
  8. package/dist/tools/agent-coordination.js +13 -4
  9. package/dist/tools/agent-instructions.js +5 -4
  10. package/dist/tools/agent-who.js +12 -3
  11. package/dist/tools/bulk-writes.js +3 -3
  12. package/dist/tools/checklist-writes.js +2 -2
  13. package/dist/tools/claim.js +2 -2
  14. package/dist/tools/doc-attachments.js +4 -4
  15. package/dist/tools/doc-comments.js +5 -5
  16. package/dist/tools/doc-export.js +3 -3
  17. package/dist/tools/doc-revisions.js +3 -3
  18. package/dist/tools/docs.js +7 -7
  19. package/dist/tools/get-timer.js +6 -1
  20. package/dist/tools/identity.js +10 -1
  21. package/dist/tools/identity.test.js +11 -0
  22. package/dist/tools/knowledge.test.js +11 -0
  23. package/dist/tools/list-git-app-installations.js +1 -1
  24. package/dist/tools/phase-b-tools.test.js +7 -0
  25. package/dist/tools/presence-scope.test.js +170 -0
  26. package/dist/tools/primer-facts.js +10 -10
  27. package/dist/tools/project-readiness-schema.js +1 -1
  28. package/dist/tools/rule-proposals.js +131 -0
  29. package/dist/tools/rule-proposals.test.js +82 -0
  30. package/dist/tools/semantic-degraded-wrapper.test.js +1 -1
  31. package/dist/tools/session-start.js +52 -16
  32. package/dist/tools/session-start.test.js +7 -6
  33. package/dist/tools/shared.js +49 -0
  34. package/dist/tools/ticket-writes.js +2 -2
  35. package/dist/tools/wiki.js +14 -6
  36. package/dist/tools/work-next.js +5 -5
  37. package/dist/tools/work-sessions.js +15 -15
  38. package/package.json +1 -1
@@ -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). 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.',
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. The answer names who runs the timer and its agent session (ORB-2550); the fallback never takes the timer of a session another harness process owns (409 `errors.time.timer_of_other_session` on stop).',
8
8
  inputSchema: z.object({}).shape,
9
9
  annotations: { readOnlyHint: true, idempotentHint: true },
10
10
  };
@@ -28,6 +28,7 @@ export function makeGetTimerHandler(client) {
28
28
  `Timer ${isPaused ? 'paused' : 'running'} on ${ticketLabel}`,
29
29
  `Elapsed: ${minutes} min (${totalSeconds}s total)`,
30
30
  `Started: ${timer.startedAt}`,
31
+ `Owner: ${timer.userFullName ?? timer.userEmail ?? timer.userId}${timer.agentSessionLabel ? `, session ${timer.agentSessionLabel}` : ', no agent session'}`,
31
32
  isPaused ? `Paused: ${timer.pausedAt}` : null,
32
33
  timer.laneFallback ? 'Matched via the single-active-timer fallback - another instance started it.' : null,
33
34
  timer.description ? `Note: ${timer.description}` : null,
@@ -46,6 +47,10 @@ export function makeGetTimerHandler(client) {
46
47
  totalSeconds,
47
48
  paused: isPaused,
48
49
  laneFallback: timer.laneFallback === true,
50
+ userId: timer.userId,
51
+ userEmail: timer.userEmail ?? null,
52
+ agentSessionId: timer.agentSessionId ?? null,
53
+ agentSessionLabel: timer.agentSessionLabel ?? null,
49
54
  },
50
55
  },
51
56
  };
@@ -4,7 +4,7 @@
4
4
  import { z } from 'zod';
5
5
  export const whoamiToolConfig = {
6
6
  title: 'Show the authenticated MCP principal',
7
- description: 'Return the authenticated user record `{id, email, fullName, isBot, workspaceLocale}` for the API key this server runs with. Useful for debugging which credential / bot identity is in use when multiple MCP configs are wired to different `orb_*` keys. **`workspaceLocale` (ORB-989)** is the language the workspace expects tickets / comments / docs in - check it before a mass-create so you write in the right language from the start instead of relying on the after-the-fact language-mismatch warning.',
7
+ description: 'Return the authenticated user record `{id, email, fullName, isBot, workspaceLocale}` for the API key this server runs with. Useful for debugging which credential / bot identity is in use when multiple MCP configs are wired to different `orb_*` keys. **`workspaceLocale` (ORB-989)** is the language the workspace expects tickets / comments / docs in - check it before a mass-create so you write in the right language from the start instead of relying on the after-the-fact language-mismatch warning. **`agentSession` (ORB-2550)** is the agent session every write of this connection is attributed to, null when the server knows none for its instance token; `foreign: true` means another harness process owns it, so writes carry no session and timers and work sessions are refused until this process has an instance token (`ORBOTO_AGENT_SESSION`) or a profile of its own.',
8
8
  inputSchema: z.object({}).shape,
9
9
  outputSchema: z.object({
10
10
  id: z.string(),
@@ -13,6 +13,7 @@ export const whoamiToolConfig = {
13
13
  isBot: z.boolean(),
14
14
  workspaceLocale: z.string().nullable(),
15
15
  actingAuthorizedBy: z.object({ id: z.string(), email: z.string(), fullName: z.string() }).nullable(),
16
+ agentSession: z.object({ id: z.string(), label: z.string(), harness: z.string().nullable(), pid: z.number().nullable(), foreign: z.boolean() }).nullable(),
16
17
  }).shape,
17
18
  annotations: { readOnlyHint: true, idempotentHint: true },
18
19
  };
@@ -32,6 +33,13 @@ export function makeWhoamiHandler(client) {
32
33
  if (me.actingAuthorizedBy) {
33
34
  lines.push(` acting as this bot, authorised by: ${me.actingAuthorizedBy.fullName} <${me.actingAuthorizedBy.email}>`);
34
35
  }
36
+ const session = me.agentSession ?? null;
37
+ if (session) {
38
+ lines.push(` session: ${session.id} (${session.label})${session.foreign ? ` - owned by ${session.harness ?? 'unknown'} pid ${session.pid ?? 0}, another process: writes carry no session, timers and work sessions are refused; give this process an instance token of its own (ORBOTO_AGENT_SESSION=<own value>) or its own identity (orboto profile bind <harness> <profile>); after a restart of the same harness, orboto session-start registers the row again` : ''}`);
39
+ }
40
+ else {
41
+ lines.push(' session: none - the server knows no session for this connection\'s instance token yet');
42
+ }
35
43
  return {
36
44
  content: [{ type: 'text', text: lines.join('\n') }],
37
45
  structuredContent: {
@@ -41,6 +49,7 @@ export function makeWhoamiHandler(client) {
41
49
  isBot,
42
50
  workspaceLocale,
43
51
  actingAuthorizedBy: me.actingAuthorizedBy ?? null,
52
+ agentSession: session,
44
53
  },
45
54
  };
46
55
  };
@@ -39,9 +39,20 @@ describe('orboto_whoami', () => {
39
39
  isBot: true,
40
40
  workspaceLocale: 'en',
41
41
  actingAuthorizedBy: null,
42
+ agentSession: null,
42
43
  });
43
44
  expect(res.content[0].text).toContain('agent-e@orboto.io');
44
45
  });
46
+ it('ORB-2550 - names the session the connection writes under, and a session another live process owns', async () => {
47
+ const session = { id: '0b0c3a1e-7f73-4c55-8a4e-1d0f0f0f0f0f', label: 'mcp-stdio#0b0c3a1e', harness: 'claude-code', pid: 4242, foreign: false };
48
+ stub([{ json: { id: 'u5', email: 'agent@orboto.io', isBot: true, agentSession: session } }, { json: { id: 'u5', email: 'agent@orboto.io', isBot: true, agentSession: { ...session, foreign: true } } }]);
49
+ const own = await makeWhoamiHandler(client)();
50
+ expect(own.structuredContent).toMatchObject({ agentSession: session });
51
+ expect(own.content[0].text).toContain(`session: ${session.id} (mcp-stdio#0b0c3a1e)`);
52
+ const foreign = await makeWhoamiHandler(client)();
53
+ expect(foreign.content[0].text).toContain('owned by claude-code pid 4242, another process: writes carry no session, timers and work sessions are refused; give this process an instance token of its own (ORBOTO_AGENT_SESSION=<own value>)');
54
+ expect(foreign.content[0].text).toContain('after a restart of the same harness, orboto session-start registers the row again');
55
+ });
45
56
  it('surfaces workspaceLocale in the text output when present (ORB-989)', async () => {
46
57
  stub([{ json: { id: 'u4', email: 'agent@orboto.io', fullName: 'Agent', isBot: true, workspaceLocale: 'de' } }]);
47
58
  const res = await makeWhoamiHandler(client)();
@@ -92,4 +92,15 @@ describe('ORB-2225 orboto_wiki_runs', () => {
92
92
  const handler = makeWikiRunsHandler({ get, post: vi.fn() });
93
93
  expect(textOf(await handler({ spaceId: 'ACME-S1' }))).toContain('r2 applied ACME-D10 "Deploy notes" 2026-09-27T09:00:00Z (ops dropped: 1)');
94
94
  });
95
+ it('ORB-2611 - a queued run says how long it waits or runs and whether it stalled', async () => {
96
+ const get = vi.fn().mockResolvedValue({ runs: [
97
+ { id: 'r3', sourceKey: 'ACME-D11', sourceTitle: 'AGENTS.md study', status: 'queued', error: null, createdAt: '2026-10-20T15:53:33Z', startedAt: null, ageSeconds: 1260, stalled: true },
98
+ { id: 'r4', sourceKey: 'ACME-D12', sourceTitle: 'Context rules', status: 'queued', error: null, createdAt: '2026-10-20T15:53:33Z', queuedAt: '2026-10-20T15:53:33Z', startedAt: '2026-10-20T16:03:33Z', ageSeconds: 780, stalled: false },
99
+ ] });
100
+ const handler = makeWikiRunsHandler({ get, post: vi.fn() });
101
+ const text = textOf(await handler({ spaceId: 'ACME-S1', status: 'queued' }));
102
+ expect(get).toHaveBeenCalledWith('/spaces/ACME-S1/llm-wiki/ingest-runs?status=queued');
103
+ expect(text).toContain('r3 queued (waiting 21 min, stalled) ACME-D11');
104
+ expect(text).toContain('r4 queued (running 3 min) ACME-D12');
105
+ });
95
106
  });
@@ -11,7 +11,7 @@
11
11
  import { z } from 'zod';
12
12
  export const listGitAppInstallationsToolConfig = {
13
13
  title: 'List GitHub App installations',
14
- description: 'Every GitHub App installation orboto knows (admin:system:read): org/user, install time, suspended state and the project keys allowed to use it.',
14
+ description: 'Every known GitHub App installation (admin:system:read): account, install time, suspension, allowed project keys.',
15
15
  inputSchema: z.object({}).shape,
16
16
  annotations: { readOnlyHint: true, idempotentHint: true },
17
17
  };
@@ -611,4 +611,11 @@ describe('orboto_get_timer', () => {
611
611
  expect(sc.timer.paused).toBe(false);
612
612
  expect(sc.timer.ticketTitle).toBe('Bug');
613
613
  });
614
+ it('ORB-2550 - names who runs the timer and in which agent session', async () => {
615
+ const startedAt = new Date().toISOString();
616
+ stub([{ json: { id: 'tm2', userId: 'u1', ticketId: 't1', startedAt, pausedAt: null, accumulatedSeconds: 0, description: null, userEmail: 'claude@orboto.io', userFullName: 'Claude Agent', agentSessionId: 's-1', agentSessionLabel: 'claude-code#0b0c3a1e' } }]);
617
+ const res = await makeGetTimerHandler(client)();
618
+ expect(res.content[0].text).toContain('Owner: Claude Agent, session claude-code#0b0c3a1e');
619
+ expect(res.structuredContent).toMatchObject({ timer: { userId: 'u1', userEmail: 'claude@orboto.io', agentSessionId: 's-1', agentSessionLabel: 'claude-code#0b0c3a1e' } });
620
+ });
614
621
  });
@@ -0,0 +1,170 @@
1
+ /**
2
+ * ORB-2553 - the MCP side of mandatory presence scope: the refusal is read
3
+ * off the API error, orboto_session_start starts with `start` and renders a
4
+ * refusal or a takeover, the proxy's default declaration is a start, and
5
+ * orboto_who prints the refused heartbeats per account.
6
+ */
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import { OrbotoApiError, OrbotoClient } from '../orboto-client.js';
9
+ import { exitedPredecessor, presenceRefusal, processAlive } from './shared.js';
10
+ import { makeSessionStartHandler, takeoverLine } from './session-start.js';
11
+ import { makeAgentWhoHandler, refusalLines } from './agent-who.js';
12
+ import { makeAgentHeartbeatHandler } from './agent-coordination.js';
13
+ import { declareEnvProjectScope } from '../project-scope.js';
14
+ import { _forgetDeclaredScopes } from '../session-scope-memory.js';
15
+ import { checkoutHash, machineId, shortHost, stdioOrigin } from '../session-origin.js';
16
+ import { createHash } from 'node:crypto';
17
+ import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ const SESSION_EXISTS = {
21
+ error: 'Another live session of this account runs in sample-repo on build-host: 0123abcd (claude-code, 42 min, ORB-7). Start again with --adopt to take over its scope, mail and leases, or with --end-previous to end it.',
22
+ errorKey: 'errors.agents.session_exists',
23
+ errorParams: { sessionId: '0123abcd-0000-4000-8000-000000000000', shortId: '0123abcd', ageMinutes: 42, ticket: 'ORB-7', host: 'build-host', repo: 'sample-repo', harness: 'claude-code', role: 'integrator', projects: 'ORB' },
24
+ };
25
+ const SCOPE_REQUIRED = { error: 'This session declares no scope', errorKey: 'errors.agents.scope_required', errorParams: { command: 'orboto session-start --role <role> --scope-projects <KEY>', notes: ['Not carried over: the earlier scope.'] } };
26
+ beforeEach(() => { _forgetDeclaredScopes(); });
27
+ afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); });
28
+ function stub(heartbeat, seen, extra = {}) {
29
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
30
+ const u = new URL(url.toString());
31
+ const path = u.pathname + u.search;
32
+ seen.push({ path, body: init?.body ? JSON.parse(init.body) : undefined });
33
+ if (u.pathname === '/v1/agent/heartbeat' && init?.method === 'POST') {
34
+ return { ok: heartbeat.status < 300, status: heartbeat.status, statusText: 'x', headers: new Headers(), json: async () => heartbeat.body, text: async () => JSON.stringify(heartbeat.body) };
35
+ }
36
+ const map = {
37
+ '/users/me/assigned-tickets': { items: [] }, '/users/me': { email: 'dev@x.io' }, '/agent-instructions': { instructions: 'rules', rulesHash: 'h' }, ...extra,
38
+ };
39
+ const key = Object.keys(map).find((k) => path.startsWith(k));
40
+ return { ok: true, status: 200, statusText: 'OK', headers: new Headers(), json: async () => (key ? map[key] : {}), text: async () => '' };
41
+ });
42
+ }
43
+ describe('ORB-2553 - the refusal on the MCP side', () => {
44
+ it('presenceRefusal reads the two refusals and their notes, nothing else', () => {
45
+ expect(presenceRefusal(new OrbotoApiError(409, JSON.stringify(SESSION_EXISTS), 'u'))).toMatchObject({ key: 'errors.agents.session_exists', params: { shortId: '0123abcd' } });
46
+ expect(presenceRefusal(new OrbotoApiError(422, JSON.stringify(SCOPE_REQUIRED), 'u'))?.message).toBe('This session declares no scope\nNot carried over: the earlier scope.');
47
+ expect(presenceRefusal(new OrbotoApiError(409, JSON.stringify({ errorKey: 'errors.agents.no_recipient' }), 'u'))).toBeNull();
48
+ expect(presenceRefusal(new OrbotoApiError(422, 'not json', 'u'))).toBeNull();
49
+ expect(presenceRefusal(new Error('x'))).toBeNull();
50
+ });
51
+ it('orboto_session_start sends start and previous, and renders a second start as not registered with the way out', async () => {
52
+ const seen = [];
53
+ stub({ status: 409, body: SESSION_EXISTS }, seen);
54
+ const client = new OrbotoClient({ baseUrl: 'http://api.test', apiKey: 'orb_k' });
55
+ const res = await makeSessionStartHandler(client)({ scope: { role: 'integrator', projectKeys: ['ORB'] }, previous: 'adopt' }, { sessionId: 'ps1' });
56
+ expect(seen.find((c) => c.path === '/v1/agent/heartbeat')?.body).toEqual({ start: true, previous: 'adopt', scope: { role: 'integrator', projectKeys: ['ORB'] } });
57
+ const text = res.content[0].text;
58
+ expect(text).toContain('Not registered: Another live session of this account runs in sample-repo on build-host: 0123abcd');
59
+ expect(text).toContain('previous: "adopt" or "end"');
60
+ expect(text).not.toContain('Peers reach exactly this session');
61
+ expect(res.structuredContent.session.refused).toContain('0123abcd');
62
+ });
63
+ it('a start that took over the previous session says what moved', async () => {
64
+ const seen = [];
65
+ const takeover = { action: 'adopted', sessionId: '0123abcd-0000-4000-8000-000000000000', shortId: '0123abcd', movedMessages: 2, movedLeases: ['ORB-7'], releasedTickets: [] };
66
+ stub({ status: 200, body: { sessionToken: 'mcp-ps2', sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: { role: 'integrator', projectKeys: ['ORB'] }, takeover } }, seen);
67
+ const client = new OrbotoClient({ baseUrl: 'http://api.test', apiKey: 'orb_k' });
68
+ const res = await makeSessionStartHandler(client)({ previous: 'adopt' }, { sessionId: 'ps2' });
69
+ expect(res.content[0].text).toContain('Adopted session 0123abcd: 2 unread message(s) and the leases on ORB-7 moved here, it is ended.');
70
+ expect(takeoverLine({ action: 'ended', shortId: 'abcd0123', movedMessages: 0, movedLeases: [], releasedTickets: ['ORB-9'] })).toBe('Ended the previous session abcd0123 in this repository; its leases on ORB-9 are released.');
71
+ });
72
+ it('the proxy declares the checkout project as a start and hands back the refusal', async () => {
73
+ const calls = [];
74
+ const refusing = {
75
+ get: async () => ({ sessionId: 'x', scope: null }),
76
+ post: async (_path, body) => { calls.push(body); throw new OrbotoApiError(409, JSON.stringify(SESSION_EXISTS), 'u'); },
77
+ };
78
+ const row = await declareEnvProjectScope(refusing, 'agent-abc', 'ACME', undefined, false);
79
+ expect(calls).toEqual([{ scope: { role: 'worker', projectKeys: ['ACME'] }, scopeDefault: true, start: true }]);
80
+ expect(row).toEqual({ refusal: SESSION_EXISTS.error });
81
+ });
82
+ it('orboto_agent_heartbeat answers a refusal as a tool error with the hint', async () => {
83
+ stub({ status: 422, body: SCOPE_REQUIRED }, []);
84
+ const res = await makeAgentHeartbeatHandler(new OrbotoClient({ baseUrl: 'http://api.test', apiKey: 'orb_k' }))({ status: 'working' }, { sessionId: 'ps3' });
85
+ expect(res.isError).toBe(true);
86
+ expect(res.content[0].text).toBe('heartbeat refused (ORB-2553) - This session declares no scope\nNot carried over: the earlier scope.');
87
+ });
88
+ it('the proxy names the hashed checkout it runs in; a linked worktree is a checkout of its own', () => {
89
+ const main = realpathSync(mkdtempSync(join(tmpdir(), 'orboto-checkout-')));
90
+ mkdirSync(join(main, '.git'));
91
+ const tree = join(main, '.claude', 'worktrees', 'agent-1');
92
+ mkdirSync(join(tree, 'sub'), { recursive: true });
93
+ writeFileSync(join(tree, '.git'), `gitdir: ${join(main, '.git', 'worktrees', 'agent-1')}\n`);
94
+ const hash = (dir) => createHash('sha256').update(dir).digest('hex').slice(0, 16);
95
+ expect(checkoutHash(join(tree, 'sub'))).toBe(hash(tree));
96
+ expect(checkoutHash(main)).toBe(hash(main));
97
+ expect(checkoutHash(tree)).not.toBe(checkoutHash(main));
98
+ expect(new URLSearchParams(stdioOrigin({})).get('checkout')).toBe(checkoutHash());
99
+ });
100
+ it('orboto_session_start sends the environment project as fallbackScope in its start beat', async () => {
101
+ vi.stubEnv('ORBOTO_PROJECT_KEY', 'acme');
102
+ vi.stubEnv('ORBOTO_SESSION_ROLE', '');
103
+ const seen = [];
104
+ stub({ status: 200, body: { sessionToken: 'mcp-ps4', sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: { role: 'worker', projectKeys: ['ACME'] } } }, seen);
105
+ await makeSessionStartHandler(new OrbotoClient({ baseUrl: 'http://api.test', apiKey: 'orb_k' }))({}, { sessionId: 'ps4' });
106
+ const beats = seen.filter((c) => c.path === '/v1/agent/heartbeat');
107
+ expect(beats.map((b) => b.body)).toEqual([{ start: true, fallbackScope: { role: 'worker', projectKeys: ['ACME'] } }]);
108
+ });
109
+ it('a second-start refusal on this machine whose pid has exited names an exited predecessor; a live pid or another machine does not', () => {
110
+ const refusal = (pid, machine, host = 'build-host') => presenceRefusal(new OrbotoApiError(409, JSON.stringify({ ...SESSION_EXISTS, errorParams: { ...SESSION_EXISTS.errorParams, pid, machineId: machine, host } }), 'u'));
111
+ const here = { machine: 'machine-a', host: 'build-host' };
112
+ expect(exitedPredecessor(refusal(4100, 'machine-a'), here, () => false)).toEqual({ shortId: '0123abcd', pid: 4100, line: 'previous session 0123abcd (pid 4100) has exited, ended it' });
113
+ expect(exitedPredecessor(refusal(4100, 'machine-a'), here, () => true)).toBeNull();
114
+ expect(exitedPredecessor(refusal(4100, 'machine-b'), here, () => false)).toBeNull();
115
+ expect(exitedPredecessor(refusal(4100, null), here, () => false)).not.toBeNull();
116
+ expect(exitedPredecessor(refusal(4100, null, 'other-host'), here, () => false)).toBeNull();
117
+ expect(exitedPredecessor(refusal(4100, 'machine-a', 'cloned-vm'), here, () => false)).toBeNull();
118
+ expect(processAlive(process.pid)).toBe(true);
119
+ expect(processAlive(2 ** 30)).toBe(false);
120
+ });
121
+ it('orboto_session_start ends an exited predecessor on its own and says so in one line', async () => {
122
+ const seen = [];
123
+ let calls = 0;
124
+ const exited = { ...SESSION_EXISTS, errorParams: { ...SESSION_EXISTS.errorParams, pid: 2 ** 30, machineId: machineId(), host: shortHost() } };
125
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
126
+ const u = new URL(url.toString());
127
+ seen.push({ path: u.pathname + u.search, body: init?.body ? JSON.parse(init.body) : undefined });
128
+ if (u.pathname === '/v1/agent/heartbeat' && init?.method === 'POST' && (calls += 1) === 1) {
129
+ return { ok: false, status: 409, statusText: 'x', headers: new Headers(), json: async () => exited, text: async () => JSON.stringify(exited) };
130
+ }
131
+ const body = u.pathname === '/v1/agent/heartbeat'
132
+ ? { sessionToken: 'mcp-ps5', sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: { role: 'worker', projectKeys: ['ORB'] }, takeover: { action: 'ended', shortId: '0123abcd', movedMessages: 0, movedLeases: [], releasedTickets: [] } }
133
+ : u.pathname === '/agent-instructions' ? { instructions: 'rules', rulesHash: 'h' } : {};
134
+ return { ok: true, status: 200, statusText: 'OK', headers: new Headers(), json: async () => body, text: async () => '' };
135
+ });
136
+ const res = await makeSessionStartHandler(new OrbotoClient({ baseUrl: 'http://api.test', apiKey: 'orb_k' }))({ scope: { role: 'worker', projectKeys: ['ORB'] } }, { sessionId: 'ps5' });
137
+ const beats = seen.filter((c) => c.path === '/v1/agent/heartbeat').map((c) => c.body);
138
+ expect(beats.map((b) => b.previous)).toEqual([undefined, 'end']);
139
+ const text = res.content[0].text;
140
+ expect(text).toContain('previous session 0123abcd (pid 1073741824) has exited, ended it');
141
+ expect(text).not.toContain('Not registered');
142
+ });
143
+ it('the proxy declaration ends an exited predecessor on its own', async () => {
144
+ const calls = [];
145
+ const exited = { ...SESSION_EXISTS, errorParams: { ...SESSION_EXISTS.errorParams, pid: 2 ** 30, machineId: machineId(), host: shortHost() } };
146
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
147
+ const restarting = {
148
+ get: async () => ({ sessionId: 'x', scope: null }),
149
+ post: async (_path, body) => {
150
+ calls.push(body);
151
+ if (calls.length === 1)
152
+ throw new OrbotoApiError(409, JSON.stringify(exited), 'u');
153
+ return { sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: { role: 'worker', projectKeys: ['ACME'] } };
154
+ },
155
+ };
156
+ const row = await declareEnvProjectScope(restarting, 'agent-abc', 'ACME', undefined, false);
157
+ expect(calls).toEqual([{ scope: { role: 'worker', projectKeys: ['ACME'] }, scopeDefault: true, start: true }, { scope: { role: 'worker', projectKeys: ['ACME'] }, scopeDefault: true, start: true, previous: 'end' }]);
158
+ expect(row).toMatchObject({ sessionId: 'cbb52195-0000-4000-8000-000000000000' });
159
+ });
160
+ it('orboto_who lists the refused heartbeats per account after the sessions', async () => {
161
+ const refusals = [{ userId: 'u1', email: 'agent@x.io', count: 3, scopeRequired: 2, sessionExists: 1, since: 't0', lastAt: '2026-10-20T10:00:00.000Z', lastReason: 'session_exists', lastHost: 'build-host', lastRepo: 'sample-repo', lastHarness: 'claude-code' }];
162
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ({ ok: true, status: 200, statusText: 'OK', headers: new Headers(), json: async () => ({ sessions: [], refusals, nextCursor: null }), text: async () => '' }));
163
+ const res = await makeAgentWhoHandler(new OrbotoClient({ baseUrl: 'http://api.test', apiKey: 'orb_k' }))({});
164
+ const text = res.content[0].text;
165
+ expect(text).toContain('Refused heartbeats in the last 24 hours (no role address, not listed):');
166
+ expect(text).toContain('- agent@x.io: 3 (no scope 2, second start 1), last 2026-10-20T10:00:00.000Z from build-host/sample-repo (claude-code)');
167
+ expect(res.structuredContent.refusals).toHaveLength(1);
168
+ expect(refusalLines(undefined)).toEqual([]);
169
+ });
170
+ });
@@ -103,12 +103,12 @@ export const primerFactAddToolConfig = {
103
103
  + FACT_WRITING_CONTRACT,
104
104
  inputSchema: z.object({
105
105
  projectKey: z.string().min(1).describe('Project key, e.g. "ORB". Case-insensitive.'),
106
- category: PrimerFactCategoryEnum.describe('Category bucket. Pick the closest fit; categories are fixed. Use "non_functional" to capture an explicit non-functional requirement (performance / security / DSGVO / operations / i18n) - the key names the sub-area, the value is the requirement; these render verbatim in the requirements-spec NFR chapter. Use "glossary" for a term/definition pair - these render only in the requirements-spec glossary chapter.'),
107
- key: z.string().min(1).max(120).describe('Short machine-readable identifier, e.g. "package-manager", "node-version", "css-framework". Lowercase + dashes preferred.'),
108
- value: z.string().min(1).max(8000).describe('Human-readable Markdown body, 1-3 sentences typically. Include a version number, a path, or a command where relevant.'),
109
- observed: z.boolean().default(false).describe('True = source is agent_observed (needs operator verification). False = manual entry by operator. A bot account or an agent key is always coerced to agent_observed server-side, whatever is sent.'),
110
- allowOversize: z.boolean().optional().describe('Override the hard size-cap block (300*2 chars). Only after a call was blocked.'),
111
- oversizeReason: z.string().min(10).max(500).optional().describe('Required with allowOversize=true - why this value genuinely needs the length. Audit-logged.'),
106
+ category: PrimerFactCategoryEnum.describe('Fixed category. non_functional: an NFR (key = area, value = requirement, rendered in the spec NFR chapter); glossary: a term/definition (spec glossary only).'),
107
+ key: z.string().min(1).max(120).describe('Short id, lowercase-dashed, e.g. "node-version".'),
108
+ value: z.string().min(1).max(8000).describe('Markdown, 1-3 sentences; name versions, paths, commands.'),
109
+ observed: z.boolean().default(false).describe('True files agent_observed (a person verifies); a bot or agent key is always agent_observed.'),
110
+ allowOversize: z.boolean().optional().describe('Override the 600-char hard cap after a blocked call.'),
111
+ oversizeReason: z.string().min(10).max(500).optional().describe('With allowOversize: why it needs the length (audited).'),
112
112
  }).shape,
113
113
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
114
114
  };
@@ -158,8 +158,8 @@ export const primerFactUpdateToolConfig = {
158
158
  value: z.string().min(1).max(8000).optional().describe('New Markdown body.'),
159
159
  category: PrimerFactCategoryEnum.optional().describe('New category.'),
160
160
  key: z.string().min(1).max(120).optional().describe('New key.'),
161
- allowOversize: z.boolean().optional().describe('Override the hard size-cap block (300*2 chars). Only after a call was blocked.'),
162
- oversizeReason: z.string().min(10).max(500).optional().describe('Required with allowOversize=true - why this value genuinely needs the length. Audit-logged.'),
161
+ allowOversize: z.boolean().optional().describe('Override the 600-char hard cap after a blocked call.'),
162
+ oversizeReason: z.string().min(10).max(500).optional().describe('With allowOversize: why it needs the length (audited).'),
163
163
  }).shape,
164
164
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
165
165
  };
@@ -213,8 +213,8 @@ export const primerFactSupersedeToolConfig = {
213
213
  category: PrimerFactCategoryEnum.describe('Category of the new fact (usually unchanged).'),
214
214
  key: z.string().min(1).max(120).describe('Key of the new fact (usually unchanged).'),
215
215
  value: z.string().min(1).max(8000).describe('Corrected Markdown body.'),
216
- allowOversize: z.boolean().optional().describe('Override the hard size-cap block (300*2 chars). Only after a call was blocked.'),
217
- oversizeReason: z.string().min(10).max(500).optional().describe('Required with allowOversize=true - why this value genuinely needs the length. Audit-logged.'),
216
+ allowOversize: z.boolean().optional().describe('Override the 600-char hard cap after a blocked call.'),
217
+ oversizeReason: z.string().min(10).max(500).optional().describe('With allowOversize: why it needs the length (audited).'),
218
218
  }).shape,
219
219
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
220
220
  };
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  export const ProjectReadinessStatusSchema = z.enum(['ok', 'missing', 'n/a']);
3
3
  export const ProjectReadinessItemIdSchema = z.enum(['project_record', 'human_manager', 'milestones', 'git_connection', 'doc_primer', 'instructions', 'tech_stack', 'commands', 'lane_assignment', 'acceptance', 'spec_settings', 'sentry_intake']);
4
- const ProjectReadinessFixSchema = z.object({ method: z.string(), route: z.string(), mcpTool: z.string(), cliCommand: z.string(), settingsPath: z.string() });
4
+ const ProjectReadinessFixSchema = z.object({ method: z.string(), route: z.string(), mcpTool: z.string(), cliCommand: z.string(), settingsPath: z.string(), note: z.string().optional() });
5
5
  export const ProjectReadinessItemSchema = z.object({
6
6
  id: ProjectReadinessItemIdSchema,
7
7
  status: ProjectReadinessStatusSchema,
@@ -0,0 +1,131 @@
1
+ /**
2
+ * ORB-2579 - rule proposals: an agent proposes a behaviour rule, a person with rule rights accepts or rejects it.
3
+ */
4
+ import { z } from 'zod';
5
+ import { sizeBlockResult } from './shared.js';
6
+ const SCOPES = ['workspace', 'customer', 'project', 'personal'];
7
+ function duplicateLines(row) {
8
+ return row.duplicates.map((d) => `\n ${d.exact ? 'exact' : 'text'} match: ${d.kind} "${d.title}" (${d.id})`).join('');
9
+ }
10
+ function scopeLabel(row) {
11
+ return row.projectKey ? `${row.scope} ${row.projectKey}` : row.scope;
12
+ }
13
+ export const ruleProposeToolConfig = {
14
+ title: 'Propose a behaviour rule',
15
+ description: 'Propose a behaviour rule for a person to accept. It binds only once a person with rule rights accepts it, and the decision comes back as agent mail; overlapping rules are named as text matches. Title: one imperative sentence; body <= 400 chars, Rule / Why / How.',
16
+ inputSchema: z.object({
17
+ title: z.string().min(1).max(120),
18
+ body: z.string().min(1).max(8000),
19
+ scope: z.enum(SCOPES).default('workspace'),
20
+ projectKey: z.string().min(1).max(32).optional(),
21
+ customerId: z.string().uuid().optional(),
22
+ reason: z.string().min(1).max(1000).optional(),
23
+ allowOversize: z.boolean().optional(),
24
+ oversizeReason: z.string().min(10).max(500).optional(),
25
+ }).shape,
26
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
27
+ };
28
+ export function makeRuleProposeHandler(client) {
29
+ return async (input) => {
30
+ let row;
31
+ try {
32
+ row = await client.post('/agent-instructions/proposals', { ...input, scope: input.scope ?? 'workspace' });
33
+ }
34
+ catch (err) {
35
+ const blocked = sizeBlockResult(err, 'Rule proposal');
36
+ if (blocked)
37
+ return blocked;
38
+ throw err;
39
+ }
40
+ const warn = row.sizeWarning ? `\nBody is over the ${row.sizeWarning.limit}-char soft limit. ${row.sizeWarning.hint}` : '';
41
+ return {
42
+ content: [{ type: 'text', text: `Proposed rule "${row.title}" (${scopeLabel(row)}, id ${row.id}), pending review by a person with rule rights.${duplicateLines(row)}${warn}` }],
43
+ structuredContent: row,
44
+ };
45
+ };
46
+ }
47
+ export const ruleProposalsToolConfig = {
48
+ title: 'List rule proposals',
49
+ description: 'List rule proposals. Your own plus the queue you may see, newest first; canDecide marks what you may accept or reject (a person with rule rights: orboto_rule_proposal_accept, orboto_rule_proposal_reject).',
50
+ inputSchema: z.object({
51
+ status: z.enum(['pending', 'accepted', 'rejected', 'all']).default('pending'),
52
+ scope: z.enum(SCOPES).optional(),
53
+ projectId: z.string().uuid().optional(),
54
+ customerId: z.string().uuid().optional(),
55
+ mine: z.boolean().optional(),
56
+ limit: z.number().int().min(1).max(100).optional(),
57
+ cursor: z.string().optional(),
58
+ }).shape,
59
+ annotations: { readOnlyHint: true, idempotentHint: true },
60
+ };
61
+ export function makeRuleProposalsHandler(client) {
62
+ return async (input = {}) => {
63
+ const p = new URLSearchParams({ status: input.status ?? 'pending' });
64
+ if (input.scope)
65
+ p.set('scope', input.scope);
66
+ if (input.projectId)
67
+ p.set('projectId', input.projectId);
68
+ if (input.customerId)
69
+ p.set('customerId', input.customerId);
70
+ if (input.mine !== undefined)
71
+ p.set('mine', String(input.mine));
72
+ if (input.limit !== undefined)
73
+ p.set('limit', String(input.limit));
74
+ if (input.cursor)
75
+ p.set('cursor', input.cursor);
76
+ const page = await client.get(`/agent-instructions/proposals?${p.toString()}`);
77
+ const lines = page.items.map((r) => `- [${r.status}] ${r.title} (${scopeLabel(r)}, id ${r.id}, by ${r.proposedByName ?? 'unknown'})${r.canDecide ? ' [you can decide]' : ''}${r.decisionReason ? `\n reason: ${r.decisionReason}` : ''}${duplicateLines(r)}`);
78
+ return {
79
+ content: [{ type: 'text', text: (lines.length ? lines.join('\n') : 'No rule proposals.') + (page.nextCursor ? `\nMore: cursor ${page.nextCursor}` : '') }],
80
+ structuredContent: page,
81
+ };
82
+ };
83
+ }
84
+ function decisionText(verb, res) {
85
+ const block = res.block ? ` as rule block ${res.block.id}${res.proposal.acceptedEdited ? ' (edited)' : ''}` : '';
86
+ return `${verb} rule proposal "${res.proposal.title}"${block}; ${res.proposerNotified ? 'the proposer got the decision as agent mail' : 'no agent mail was sent'}.`;
87
+ }
88
+ export const ruleProposalAcceptToolConfig = {
89
+ title: 'Accept a rule proposal',
90
+ description: 'Accept a pending rule proposal as a rule block, as proposed or edited. A person with rule rights on its scope only, on the explicit instruction of that person; a bot account or an agent key gets 403 errors.agent_rules.review_needs_person. must-read stays off unless set.',
91
+ inputSchema: z.object({
92
+ proposalId: z.string().uuid(),
93
+ title: z.string().min(1).max(120).optional(),
94
+ body: z.string().min(1).max(8000).optional(),
95
+ mustRead: z.boolean().optional(),
96
+ note: z.string().min(1).max(1000).optional(),
97
+ allowOversize: z.boolean().optional(),
98
+ oversizeReason: z.string().min(10).max(500).optional(),
99
+ }).shape,
100
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
101
+ };
102
+ export function makeRuleProposalAcceptHandler(client) {
103
+ return async ({ proposalId, ...body }) => {
104
+ let res;
105
+ try {
106
+ res = await client.post(`/agent-instructions/proposals/${encodeURIComponent(proposalId)}/accept`, body);
107
+ }
108
+ catch (err) {
109
+ const blocked = sizeBlockResult(err, 'Rule proposal accept');
110
+ if (blocked)
111
+ return blocked;
112
+ throw err;
113
+ }
114
+ return { content: [{ type: 'text', text: decisionText('Accepted', res) }], structuredContent: res };
115
+ };
116
+ }
117
+ export const ruleProposalRejectToolConfig = {
118
+ title: 'Reject a rule proposal',
119
+ description: 'Reject a pending rule proposal with a reason the proposer receives. A person with rule rights on its scope only, on the explicit instruction of that person; a bot account or an agent key gets 403 errors.agent_rules.review_needs_person.',
120
+ inputSchema: z.object({
121
+ proposalId: z.string().uuid(),
122
+ reason: z.string().min(3).max(1000),
123
+ }).shape,
124
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
125
+ };
126
+ export function makeRuleProposalRejectHandler(client) {
127
+ return async ({ proposalId, reason }) => {
128
+ const res = await client.post(`/agent-instructions/proposals/${encodeURIComponent(proposalId)}/reject`, { reason });
129
+ return { content: [{ type: 'text', text: decisionText('Rejected', res) }], structuredContent: res };
130
+ };
131
+ }
@@ -0,0 +1,82 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { OrbotoClient } from '../orboto-client.js';
3
+ import { makeRuleProposalAcceptHandler, makeRuleProposalRejectHandler, makeRuleProposeHandler, makeRuleProposalsHandler } from './rule-proposals.js';
4
+ afterEach(() => { vi.restoreAllMocks(); });
5
+ function stub(responses) {
6
+ const calls = [];
7
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
8
+ calls.push({ url: url.toString(), method: init?.method ?? 'GET', body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined });
9
+ const r = responses.shift();
10
+ if (!r)
11
+ throw new Error('unexpected extra fetch');
12
+ const status = r.status ?? 200;
13
+ return { ok: status < 400, status, statusText: 'x', json: async () => r.json, text: async () => JSON.stringify(r.json) };
14
+ });
15
+ return calls;
16
+ }
17
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
18
+ const ROW = {
19
+ id: '11111111-1111-1111-1111-111111111111', scope: 'project', projectKey: 'ORB', title: 'Run the gates one by one', status: 'pending',
20
+ duplicates: [{ kind: 'rule', id: '22222222-2222-2222-2222-222222222222', title: 'Gates run separately', exact: false }],
21
+ proposedByName: 'Claude Agent', decisionReason: null, canDecide: false,
22
+ };
23
+ describe('rule proposal MCP tools (ORB-2579)', () => {
24
+ it('propose posts the proposal with the workspace default and names the text matches', async () => {
25
+ const calls = stub([{ status: 201, json: ROW }]);
26
+ const res = await makeRuleProposeHandler(client)({ title: ROW.title, body: 'Rule: one gate per call.', projectKey: 'ORB', scope: 'project', reason: 'a chained call ran one gate' });
27
+ expect(calls[0]).toMatchObject({ method: 'POST', url: 'https://orboto.example.com/agent-instructions/proposals' });
28
+ expect(calls[0].body).toEqual({ title: ROW.title, body: 'Rule: one gate per call.', projectKey: 'ORB', scope: 'project', reason: 'a chained call ran one gate' });
29
+ const text = res.content[0].text;
30
+ expect(text).toContain('pending review by a person with rule rights');
31
+ expect(text).toContain('text match: rule "Gates run separately"');
32
+ expect(text).not.toMatch(/%/);
33
+ const calls2 = stub([{ status: 201, json: { ...ROW, scope: 'workspace', projectKey: null, duplicates: [] } }]);
34
+ await makeRuleProposeHandler(client)({ title: 'x', body: 'y' });
35
+ expect(calls2[0].body).toEqual({ title: 'x', body: 'y', scope: 'workspace' });
36
+ });
37
+ it('propose turns the hard-cap refusal into a blocked result', async () => {
38
+ stub([{ status: 422, json: { error: 'too long', errorKey: 'errors.agent_content.oversize', sizeWarning: { chars: 900, limit: 800, hint: 'shorten it' } } }]);
39
+ const res = await makeRuleProposeHandler(client)({ title: 'x', body: 'y'.repeat(900) });
40
+ expect(res.isError).toBe(true);
41
+ expect(res.content[0].text).toContain('Rule proposal blocked');
42
+ });
43
+ it('list sends status, mine and cursor and marks what the caller can decide', async () => {
44
+ const calls = stub([{ json: { items: [{ ...ROW, canDecide: true }], nextCursor: 'abc' } }]);
45
+ const res = await makeRuleProposalsHandler(client)({ status: 'all', mine: true, cursor: 'c1' });
46
+ expect(calls[0].url).toBe('https://orboto.example.com/agent-instructions/proposals?status=all&mine=true&cursor=c1');
47
+ const text = res.content[0].text;
48
+ expect(text).toContain('[pending] Run the gates one by one (project ORB');
49
+ expect(text).toContain('[you can decide]');
50
+ expect(text).toContain('More: cursor abc');
51
+ });
52
+ it('list defaults to the pending queue and says when it is empty', async () => {
53
+ const calls = stub([{ json: { items: [], nextCursor: null } }]);
54
+ const res = await makeRuleProposalsHandler(client)();
55
+ expect(calls[0].url).toBe('https://orboto.example.com/agent-instructions/proposals?status=pending');
56
+ expect(res.content[0].text).toBe('No rule proposals.');
57
+ });
58
+ it('list forwards the scope, project, customer and limit filters', async () => {
59
+ const calls = stub([{ json: { items: [], nextCursor: null } }]);
60
+ await makeRuleProposalsHandler(client)({ scope: 'project', projectId: '44444444-4444-4444-8444-444444444444', customerId: '55555555-5555-4555-8555-555555555555', limit: 10 });
61
+ expect(calls[0].url).toBe('https://orboto.example.com/agent-instructions/proposals?status=pending&scope=project&projectId=44444444-4444-4444-8444-444444444444&customerId=55555555-5555-4555-8555-555555555555&limit=10');
62
+ });
63
+ it('accept posts the edits and names the new block', async () => {
64
+ const calls = stub([{ json: { proposal: { ...ROW, status: 'accepted', acceptedEdited: true }, block: { id: 'b1' }, proposerNotified: true } }]);
65
+ const res = await makeRuleProposalAcceptHandler(client)({ proposalId: ROW.id, title: 'Short', mustRead: true, note: 'tightened' });
66
+ expect(calls[0]).toMatchObject({ method: 'POST', url: `https://orboto.example.com/agent-instructions/proposals/${ROW.id}/accept`, body: { title: 'Short', mustRead: true, note: 'tightened' } });
67
+ expect(res.content[0].text).toBe(`Accepted rule proposal "${ROW.title}" as rule block b1 (edited); the proposer got the decision as agent mail.`);
68
+ });
69
+ it('accept surfaces the person-only refusal and the hard-cap block', async () => {
70
+ stub([{ status: 403, json: { error: 'A person with rule rights decides on a rule proposal', errorKey: 'errors.agent_rules.review_needs_person' } }]);
71
+ await expect(makeRuleProposalAcceptHandler(client)({ proposalId: ROW.id })).rejects.toThrow(/403/);
72
+ stub([{ status: 422, json: { error: 'too long', sizeWarning: { chars: 900, limit: 800, hint: 'shorten it' } } }]);
73
+ const blocked = await makeRuleProposalAcceptHandler(client)({ proposalId: ROW.id, body: 'x'.repeat(900) });
74
+ expect(blocked.isError).toBe(true);
75
+ });
76
+ it('reject posts the reason', async () => {
77
+ const calls = stub([{ json: { proposal: { ...ROW, status: 'rejected', decisionReason: 'covered' }, block: null, proposerNotified: false } }]);
78
+ const res = await makeRuleProposalRejectHandler(client)({ proposalId: ROW.id, reason: 'covered' });
79
+ expect(calls[0]).toMatchObject({ method: 'POST', url: `https://orboto.example.com/agent-instructions/proposals/${ROW.id}/reject`, body: { reason: 'covered' } });
80
+ expect(res.content[0].text).toBe(`Rejected rule proposal "${ROW.title}"; no agent mail was sent.`);
81
+ });
82
+ });
@@ -57,7 +57,7 @@ it('search, search-docs, ask-docs and check-similar print the degraded line on s
57
57
  child.on('close', (code) => code === 0 ? ok({ out, err }) : no(new Error(`Wrapper exit ${code}: ${err}`)));
58
58
  child.stdin.end('');
59
59
  });
60
- for (const args of [['search', 'frobnicator'], ['search-docs', 'frobnicator'], ['ask-docs', 'how to restart'], ['check-similar', 'ORB', 'Frobnicator jams']]) {
60
+ for (const args of [['search', 'frobnicator'], ['search-docs', 'frobnicator'], ['ask-docs', 'how to restart'], ['check-similar', 'ORB', 'Frobnicator jams'], ['ticket', 'check-similar', 'ORB', 'Frobnicator jams']]) {
61
61
  const { out, err } = await run(args);
62
62
  expect(err, args.join(' ')).toContain(LINE);
63
63
  expect(out, args.join(' ')).not.toContain(LINE);