@orboto/mcp 0.226.2 → 0.228.1

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 (44) 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 +101 -36
  9. package/dist/tools/agent-coordination.test.js +62 -22
  10. package/dist/tools/agent-instructions.js +5 -4
  11. package/dist/tools/agent-messages-compact.test.js +3 -3
  12. package/dist/tools/agent-messages.js +24 -4
  13. package/dist/tools/agent-presence-row.js +22 -0
  14. package/dist/tools/agent-who.js +18 -18
  15. package/dist/tools/agent-who.test.js +9 -2
  16. package/dist/tools/bulk-writes.js +3 -3
  17. package/dist/tools/checklist-writes.js +2 -2
  18. package/dist/tools/claim.js +2 -2
  19. package/dist/tools/doc-attachments.js +4 -4
  20. package/dist/tools/doc-comments.js +5 -5
  21. package/dist/tools/doc-export.js +3 -3
  22. package/dist/tools/doc-revisions.js +3 -3
  23. package/dist/tools/docs.js +7 -7
  24. package/dist/tools/get-timer.js +6 -1
  25. package/dist/tools/identity.js +10 -1
  26. package/dist/tools/identity.test.js +11 -0
  27. package/dist/tools/list-git-app-installations.js +1 -1
  28. package/dist/tools/phase-b-tools.test.js +7 -0
  29. package/dist/tools/presence-scope.test.js +170 -0
  30. package/dist/tools/primer-facts.js +15 -15
  31. package/dist/tools/project-readiness-schema.js +1 -1
  32. package/dist/tools/read-budget.test.js +161 -0
  33. package/dist/tools/responsibilities.test.js +1 -1
  34. package/dist/tools/rule-proposals.js +131 -0
  35. package/dist/tools/rule-proposals.test.js +82 -0
  36. package/dist/tools/semantic-degraded-wrapper.test.js +1 -1
  37. package/dist/tools/session-start.js +52 -16
  38. package/dist/tools/session-start.test.js +8 -7
  39. package/dist/tools/shared.js +49 -0
  40. package/dist/tools/ticket-writes.js +2 -2
  41. package/dist/tools/wiki.js +2 -2
  42. package/dist/tools/work-next.js +5 -5
  43. package/dist/tools/work-sessions.js +15 -15
  44. package/package.json +1 -1
@@ -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
+ });
@@ -99,16 +99,16 @@ export function makePrimerFactListHandler(client) {
99
99
  }
100
100
  export const primerFactAddToolConfig = {
101
101
  title: 'Record a new project primer fact',
102
- description: 'Record a new structured fact about the project that future AI agents should see at session start. Use when you learn something during work that is missing from the primer (a library version, a convention, a deployment quirk, an architecture decision). Note: the auto-generated primer does NOT automatically include CLAUDE.md / AGENTS.md content unless the operator configured repo briefings and the API host can read those files from disk - most deployments cannot. Treat key conventions documented in CLAUDE.md / AGENTS.md as fair game to record here so they survive cross-deployment. Set observed=true when you are recording from a bot/agent context - the fact will land as agent_observed and require operator verification before losing the (observed) marker.'
102
+ description: 'Record a new structured fact about the project that future AI agents should see at session start. Use when you learn something during work that is missing from the primer (a library version, a convention, a deployment quirk, an architecture decision). Note: the auto-generated primer does NOT automatically include CLAUDE.md / AGENTS.md content unless the operator configured repo briefings and the API host can read those files from disk - most deployments cannot. Treat key conventions documented in CLAUDE.md / AGENTS.md as fair game to record here so they survive cross-deployment. Set observed=true when you are recording from a bot/agent context - the fact will land as agent_observed and require a person to verify it before it loses the (observed) marker (CLI: orboto primer-fact add <KEY> --category <c> --key <k> --value "<v>" --observed).'
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. Bots without project:edit get coerced to agent_observed server-side regardless.'),
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
  };
@@ -151,15 +151,15 @@ export function makePrimerFactAddHandler(client) {
151
151
  }
152
152
  export const primerFactUpdateToolConfig = {
153
153
  title: 'Update an existing primer fact',
154
- description: 'Update an existing fact. Use this when the value has changed (e.g. version bumped) or you are renaming the key. Bumps last_verified_at automatically when the value changes; pure renames leave it untouched. To replace a fact while preserving history, use orboto_primer_fact_supersede instead.'
154
+ description: 'Update an existing fact. Use this when the value has changed (e.g. version bumped) or you are renaming the key. Bumps last_verified_at automatically when the value changes; pure renames leave it untouched. To replace a fact while preserving history, use orboto_primer_fact_supersede instead. A person decides: a bot account or an agent key gets 403 errors.primer_facts.update_needs_person and proposes a corrected fact with orboto_primer_fact_add (observed) instead.'
155
155
  + FACT_WRITING_CONTRACT,
156
156
  inputSchema: z.object({
157
157
  factId: z.string().min(1).describe('UUID of the fact to update. Read it from orboto_primer_fact_list output.'),
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
  };
@@ -206,15 +206,15 @@ export function makePrimerFactUpdateHandler(client) {
206
206
  }
207
207
  export const primerFactSupersedeToolConfig = {
208
208
  title: 'Replace a primer fact while preserving history',
209
- description: 'Replace a fact with a corrected version, preserving the old one in history (linked via superseded_by_id). Use when you discover a previously recorded fact was wrong; do not just delete it. The old row gets a tombstoned key suffix so the partial unique index does not collide; the new row starts unverified and needs operator verification before losing the (observed) marker.'
209
+ description: 'Replace a fact with a corrected version, preserving the old one in history (linked via superseded_by_id). Use when you discover a previously recorded fact was wrong; do not just delete it. The old row gets a tombstoned key suffix so the partial unique index does not collide; the new row starts unverified and needs a person to verify it before it loses the (observed) marker. A person decides: a bot account or an agent key gets 403 errors.primer_facts.supersede_needs_person and proposes the corrected fact with orboto_primer_fact_add (observed) instead.'
210
210
  + FACT_WRITING_CONTRACT,
211
211
  inputSchema: z.object({
212
212
  oldFactId: z.string().min(1).describe('UUID of the fact being replaced.'),
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
  };
@@ -247,7 +247,7 @@ export function makePrimerFactSupersedeHandler(client) {
247
247
  }
248
248
  export const primerFactVerifyToolConfig = {
249
249
  title: 'Verify an agent-observed primer fact',
250
- description: 'Promote an agent-observed fact to verified. A person verifies: a bot account or an agent key gets 403 errors.primer_facts.verify_needs_person, so an agent proposes and never confirms. Removes the (observed) marker from the rendered primer and stamps verifiedBy / verifiedAt.',
250
+ description: 'Promote an agent-observed fact to verified. A person verifies: a bot account or an agent key gets 403 errors.primer_facts.verify_needs_person, so an agent proposes and never confirms (CLI: orboto primer-fact verify). Removes the (observed) marker from the rendered primer and stamps verifiedBy / verifiedAt.',
251
251
  inputSchema: z.object({
252
252
  factId: z.string().min(1).describe('UUID of the fact to verify.'),
253
253
  }).shape,
@@ -274,7 +274,7 @@ export function makePrimerFactVerifyHandler(client) {
274
274
  }
275
275
  export const primerFactDeleteToolConfig = {
276
276
  title: 'Delete a primer fact',
277
- description: 'Hard-delete a fact. Prefer orboto_primer_fact_supersede when the fact is being replaced; only delete when the fact was wrong from the start or no longer relevant. Writes an audit-log entry; the row itself is gone (no soft-delete).',
277
+ description: 'Hard-delete a fact. Prefer orboto_primer_fact_supersede when the fact is being replaced; only delete when the fact was wrong from the start or no longer relevant. Writes an audit-log entry; the row itself is gone (no soft-delete). Deleting an unverified agent-observed fact is the Reject of the review queue. A person decides: a bot account or an agent key gets 403 errors.primer_facts.reject_needs_person (observed proposal) or errors.primer_facts.delete_needs_person (CLI: orboto primer-fact reject).',
278
278
  inputSchema: z.object({
279
279
  factId: z.string().min(1).describe('UUID of the fact to delete.'),
280
280
  reason: z.string().max(500).optional().describe('Optional human-readable reason; recorded in the audit log details for future operators.'),
@@ -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,161 @@
1
+ /**
2
+ * ORB-2548 - the evidence cases of the OCP integrator (2026-10-16) answered
3
+ * inside the 4,000-character budget: presence returned 236,455 characters
4
+ * with 1 of 32 rows kept, and finding one session took ~230 kB of expand
5
+ * calls; the inbox needed one to three expand calls.
6
+ */
7
+ import { afterEach, describe, expect, it, vi } from 'vitest';
8
+ import { OrbotoClient } from '../orboto-client.js';
9
+ import { DEFAULT_BUDGET_CHARS, applyResponseBudget, budgetFor, measureHalves } from '../response-budget.js';
10
+ import { makeAgentPresenceHandler } from './agent-coordination.js';
11
+ import { makeAgentWhoHandler } from './agent-who.js';
12
+ import { makeAgentMessagesHandler } from './agent-messages.js';
13
+ afterEach(() => { vi.restoreAllMocks(); });
14
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
15
+ const uuid = (n, tail = 0) => `${String(n).padStart(8, '0')}-0000-4000-8000-${String(tail).padStart(12, '0')}`;
16
+ /** One inventory row in the shape the evidence measured: a bot account that is a member of 80 projects (~10 kB, the live rows measured 1.7 to 10.3 kB). */
17
+ function inventoryRow(i) {
18
+ const account = Math.floor(i / 4);
19
+ return {
20
+ userId: uuid(account, 1), userEmail: `agent-${account}-f82fd0ea@agents.internal`, userFullName: `Agent ${account}`, kind: 'agent', isBot: true,
21
+ actsAs: null, owner: { id: uuid(900, 2), name: 'Operator Person', email: 'operator@example.test' }, autonomyPaused: false,
22
+ sessionId: uuid(i, 3), status: 'idle', lane: null,
23
+ projects: Array.from({ length: 80 }, (_, p) => ({ id: uuid(p, 4), key: `P${String(p).padStart(3, '0')}`, name: `Project number ${p} - long name` })),
24
+ connections: [{ type: 'api_key', label: 'agent key for the build host' }, { type: 'channel', label: 'claude-code wake channel' }],
25
+ wake: 'channel', workingOnTicket: { id: uuid(i, 5), key: `ORB-${2500 + i}`, title: 'A ticket title of ordinary length for a working session', projectKey: 'ORB' },
26
+ workSessions: [{ ticketId: uuid(i, 5), ticketKey: `ORB-${2500 + i}`, role: 'implementation', leaseUntil: '2026-10-16T10:15:00.000Z', resourceClaims: [] }],
27
+ capabilities: ['writes-tickets', 'reviews'],
28
+ clientInfo: { name: 'claude-code', host: 'build-host.fritz.box', channel: 'claude-code', credential: 'api_key', credentialId: uuid(i, 6), credentialActor: 'agent', channelStart: 'orboto-claude', contextReportedAt: '2026-10-16T09:51:55.631Z' },
29
+ scope: { projectKeys: ['OCP'], role: i === 17 ? 'integrator' : 'worker' },
30
+ unreadMessages: 3, wakes: { count: 4, precision: 1, wrongWakeContextTokens: 0, backlogSeen: 0 },
31
+ guard: { held: 0, released: 2, stalled: 0, consecutiveHolds: 0, lastOutcome: 'released', lastReason: null, lastAt: '2026-10-16T09:50:00.000Z', paused: false },
32
+ knowledge: { writes: 0, asks: 1, lastAt: null, localPending: false },
33
+ restartPending: false, restartReason: null, restartable: true, reconnects: 2, lastReconnectAt: '2026-10-16T09:40:00.000Z',
34
+ lastSeenAt: '2026-10-16 09:54:00.504+00', createdAt: '2026-10-16 08:01:12.719715+00',
35
+ origin: { host: 'build-host', machineId: '4ea7904991187bf1', repo: 'orboto-control-plane', pid: 15503 + i, startedAt: '2026-10-16T08:00:00.000Z', client: 'orboto-mcp', clientVersion: '0.224.0', harness: 'claude-code', credential: 'api_key', known: true },
36
+ state: 'live', activity: 'live', touchedAt: '2026-10-16 09:54:00.504+00',
37
+ };
38
+ }
39
+ const WORKSPACE = Array.from({ length: 32 }, (_, i) => inventoryRow(i));
40
+ /** The presence read as the API answers it: the ORB-2548 filters and the page on the server (the API side is tested in apps/api). */
41
+ function servePresence() {
42
+ const urls = [];
43
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
44
+ const url = new URL(String(input));
45
+ urls.push(url.pathname + url.search);
46
+ const q = url.searchParams;
47
+ const rows = WORKSPACE.filter((r) => (!q.get('sessionId') || r.sessionId.startsWith(q.get('sessionId')))
48
+ && (!q.get('email') || r.userEmail === q.get('email'))
49
+ && (!q.get('role') || r.scope.role === q.get('role')));
50
+ const start = q.get('cursor') ? Number(q.get('cursor')) : 0;
51
+ const limit = Number(q.get('limit') ?? rows.length);
52
+ const page = rows.slice(start, start + limit);
53
+ const nextCursor = start + limit < rows.length ? String(start + limit) : null;
54
+ const sessions = page.map((r) => ({
55
+ sessionId: r.sessionId, shortId: r.sessionId.slice(0, 8), userId: r.userId, email: r.userEmail, isBot: true, role: r.scope.role,
56
+ projectKeys: r.scope.projectKeys, ticketKeys: [], addresses: [`${r.scope.role}@OCP`], lastSeenAt: r.lastSeenAt,
57
+ channel: { announced: 'claude-code', online: true }, wake: r.wake, currentTicket: { key: r.workingOnTicket.key, title: r.workingOnTicket.title },
58
+ status: r.status, host: 'build-host.fritz.box', origin: r.origin, state: r.state, activity: r.activity, createdAt: r.createdAt,
59
+ }));
60
+ const body = q.get('include') === 'inventory' ? { sessions, inventory: page, nextCursor } : { sessions, nextCursor };
61
+ return { ok: true, status: 200, statusText: 'OK', json: async () => body, text: async () => JSON.stringify(body) };
62
+ });
63
+ return urls;
64
+ }
65
+ function underBudget(tool, result) {
66
+ const halves = measureHalves(result);
67
+ const outcome = applyResponseBudget(tool, result, {});
68
+ return { ...halves, truncated: outcome.truncatedChars, budget: budgetFor(tool, {}) };
69
+ }
70
+ describe('ORB-2548 - presence reads fit the MCP response budget', () => {
71
+ it('reproduces the evidence: the unfiltered inventory of 32 sessions is over 230 kB, far over the 4,000 budget', () => {
72
+ const all = JSON.stringify({ sessions: WORKSPACE }).length;
73
+ expect(all).toBeGreaterThan(230_000);
74
+ expect(budgetFor('orboto_agent_presence', {})).toBe(DEFAULT_BUDGET_CHARS);
75
+ });
76
+ it('one session by its short id comes back whole, under budget, without an expand handle', async () => {
77
+ const urls = servePresence();
78
+ const result = await makeAgentPresenceHandler(client)({ sessionId: uuid(17, 3).slice(0, 8) });
79
+ const m = underBudget('orboto_agent_presence', result);
80
+ expect(urls[0]).toBe(`/v1/agent/who?include=inventory&sessionId=${uuid(17, 3).slice(0, 8)}&limit=4`);
81
+ expect(m.truncated).toBe(0);
82
+ expect(m.structuredChars).toBeLessThan(DEFAULT_BUDGET_CHARS);
83
+ expect(m.textChars).toBeLessThan(DEFAULT_BUDGET_CHARS);
84
+ const { sessions } = result.structuredContent;
85
+ expect(sessions).toEqual([expect.objectContaining({ sessionId: uuid(17, 3), scope: { projectKeys: ['OCP'], role: 'integrator' } })]);
86
+ expect(result.content[0].text).toContain(`instance ${uuid(17, 3)}`);
87
+ });
88
+ it('an account email with four sessions, and the unfiltered first page of four, stay under budget with nextCursor for the rest', async () => {
89
+ servePresence();
90
+ const byEmail = await makeAgentPresenceHandler(client)({ email: 'agent-2-f82fd0ea@agents.internal' });
91
+ expect(byEmail.structuredContent.sessions).toHaveLength(4);
92
+ expect(underBudget('orboto_agent_presence', byEmail)).toMatchObject({ truncated: 0 });
93
+ const first = await makeAgentPresenceHandler(client)({});
94
+ const m = underBudget('orboto_agent_presence', first);
95
+ expect(m.truncated).toBe(0);
96
+ expect(Math.max(m.textChars, m.structuredChars)).toBeLessThan(DEFAULT_BUDGET_CHARS);
97
+ expect(first.structuredContent.nextCursor).toBe('4');
98
+ expect(first.content[0].text).toContain('call again with cursor "4"');
99
+ });
100
+ it('orboto_who answers the same filtered set under budget', async () => {
101
+ const urls = servePresence();
102
+ const result = await makeAgentWhoHandler(client)({ role: 'integrator' });
103
+ expect(urls[0]).toBe('/v1/agent/who?role=integrator&limit=4');
104
+ expect(result.structuredContent.sessions.map((s) => s.sessionId)).toEqual([uuid(17, 3)]);
105
+ expect(underBudget('orboto_who', result)).toMatchObject({ truncated: 0 });
106
+ const page = await makeAgentWhoHandler(client)({});
107
+ expect(underBudget('orboto_who', page)).toMatchObject({ truncated: 0 });
108
+ });
109
+ });
110
+ const party = (n, role) => ({ userId: uuid(n, 7), email: `agent-${n}@orboto.io`, sessionId: uuid(n, 8), role, label: `agent-${n}@orboto.io (${role}, ${uuid(n, 8).slice(0, 8)})` });
111
+ /** An inbox message in the measured live shape: a 182-character subject, a 1 kB payload, the compact excerpt. */
112
+ function message(i, threadId) {
113
+ const body = `${'Status of the rollout on the tenant hosts, with the commands that ran and their exit codes. '.repeat(11)}`;
114
+ return {
115
+ id: uuid(100 + i, 9), fromUserId: uuid(1, 7), toUserId: uuid(2, 7), fromSessionId: uuid(1, 8), toSessionId: null,
116
+ from: party(1, 'integrator'), to: party(2, 'worker'), kind: i % 2 ? 'complete' : 'request',
117
+ subject: `OCP-${800 + i}: ${'rollout of the verified release to every tenant host, the backup first and core before the tenants, '.repeat(2).slice(0, 172)}`,
118
+ payload: { body }, threadId, projectKey: 'OCP', createdAt: `2026-10-16 09:${String(10 + i).padStart(2, '0')}:00.504123+00`,
119
+ deliveredAt: null, readAt: null, dismissed: null, excerpt: `body: ${body.slice(0, 294)}`, payloadChars: body.length + 6,
120
+ };
121
+ }
122
+ function serveInbox(messages) {
123
+ const urls = [];
124
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
125
+ const url = new URL(String(input));
126
+ urls.push(url.pathname + url.search);
127
+ const q = url.searchParams;
128
+ const rows = messages.filter((m) => (!q.get('threadId') || m.id === q.get('threadId') || m.threadId === q.get('threadId')) && (!q.get('kind') || m.kind === q.get('kind')));
129
+ const limit = Number(q.get('limit'));
130
+ const body = { messages: rows.slice(0, limit), sessionId: uuid(2, 8), counts: { unread: rows.length, olderThan7Days: 0 }, nextCursor: rows.length > limit ? 'next-page' : null };
131
+ return { ok: true, status: 200, statusText: 'OK', json: async () => body, text: async () => JSON.stringify(body) };
132
+ });
133
+ return urls;
134
+ }
135
+ describe('ORB-2548 - inbox reads fit the MCP response budget', () => {
136
+ const inbox = [...Array.from({ length: 12 }, (_, i) => message(i, null)), ...Array.from({ length: 4 }, (_, i) => message(20 + i, uuid(100, 9)))];
137
+ it('the default compact page (4) of full-size messages fits; the old default of 10 did not', async () => {
138
+ serveInbox(inbox);
139
+ const page = await makeAgentMessagesHandler(client)({});
140
+ const m = underBudget('orboto_messages', page);
141
+ expect(m.truncated).toBe(0);
142
+ expect(Math.max(m.textChars, m.structuredChars)).toBeLessThan(DEFAULT_BUDGET_CHARS);
143
+ expect(page.structuredContent.nextCursor).toBe('next-page');
144
+ serveInbox(inbox);
145
+ const old = await makeAgentMessagesHandler(client)({ limit: 10 });
146
+ expect(underBudget('orboto_messages', old).truncated).toBeGreaterThan(0);
147
+ });
148
+ it('one thread and one kind are filtered on the server and come back under budget', async () => {
149
+ const urls = serveInbox(inbox);
150
+ const thread = await makeAgentMessagesHandler(client)({ threadId: uuid(100, 9) });
151
+ expect(urls[0]).toContain(`threadId=${uuid(100, 9)}`);
152
+ expect(thread.structuredContent).toMatchObject({ nextCursor: 'next-page' });
153
+ expect(thread.structuredContent.messages).toHaveLength(4);
154
+ expect(underBudget('orboto_messages', thread)).toMatchObject({ truncated: 0 });
155
+ const sent = await makeAgentMessagesHandler(client)({ direction: 'sent', kind: 'request', limit: 3 });
156
+ expect(urls[1]).toContain('direction=sent');
157
+ expect(urls[1]).toContain('kind=request');
158
+ expect(urls[1]).not.toContain('excludeRef');
159
+ expect(underBudget('orboto_messages', sent)).toMatchObject({ truncated: 0 });
160
+ });
161
+ });
@@ -51,7 +51,7 @@ describe('orboto_who for', () => {
51
51
  const urls = [];
52
52
  respond(200, { sessions: [], owner: match }, urls);
53
53
  const result = await makeAgentWhoHandler(client)({ for: 'unity asset' });
54
- expect(urls[0]).toBe('https://orboto.example.com/v1/agent/who?for=unity+asset');
54
+ expect(urls[0]).toBe('https://orboto.example.com/v1/agent/who?for=unity+asset&limit=4');
55
55
  const text = result.content[0].text;
56
56
  expect(text).toContain('Owner:\n- worker@GAMES');
57
57
  expect(text).toContain('No live session answers to worker@GAMES - send with queue: true and project GAMES');
@@ -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
+ }