@orboto/mcp 0.181.1 → 0.182.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.
- package/dist/orboto-client.js +2 -2
- package/dist/required-rules-wrapper.test.js +113 -0
- package/dist/required-rules.js +92 -0
- package/dist/required-rules.test.js +110 -0
- package/dist/resources.js +3 -2
- package/dist/resources.test.js +12 -2
- package/dist/server.js +8 -8
- package/dist/session-nudge.js +9 -6
- package/dist/session-nudge.test.js +8 -3
- package/dist/session-nudge.transport.test.js +21 -3
- package/dist/tool-annotations.test.js +22 -0
- package/dist/tool-docs.test.js +1 -0
- package/dist/tools/git-health-reasons.js +10 -0
- package/dist/tools/session-start.js +9 -19
- package/dist/tools/session-start.test.js +8 -8
- package/dist/tools/ticket-writes.js +6 -5
- package/dist/tools/ticket-writes.test.js +6 -0
- package/dist/tools/work-next.js +5 -3
- package/dist/tools/work-sessions-next.test.js +32 -6
- package/dist/tools/work-sessions-shared.js +1 -6
- package/dist/tools/work-sessions-start.test.js +3 -0
- package/dist/tools/work-sessions.js +9 -5
- package/dist/with-metrics.js +11 -3
- package/package.json +1 -1
package/dist/orboto-client.js
CHANGED
|
@@ -107,8 +107,8 @@ export class OrbotoClient {
|
|
|
107
107
|
return res;
|
|
108
108
|
}
|
|
109
109
|
/** GET a JSON endpoint. Throws `OrbotoApiError` on non-2xx. */
|
|
110
|
-
async get(path) {
|
|
111
|
-
const res = await this.authedFetch(this.fullUrl(path), { method: 'GET' });
|
|
110
|
+
async get(path, options = {}) {
|
|
111
|
+
const res = await this.authedFetch(this.fullUrl(path), { method: 'GET', signal: options.signal });
|
|
112
112
|
return (await res.json());
|
|
113
113
|
}
|
|
114
114
|
/** POST a JSON body. */
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { cp, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
|
9
|
+
const cleanup = [];
|
|
10
|
+
afterEach(async () => { for (const fn of cleanup.splice(0).reverse())
|
|
11
|
+
await fn(); });
|
|
12
|
+
async function fixture() {
|
|
13
|
+
const dir = await mkdtemp(join(tmpdir(), 'orboto-rules-wrapper-'));
|
|
14
|
+
cleanup.push(() => rm(dir, { recursive: true, force: true }));
|
|
15
|
+
await mkdir(join(dir, 'skills/orboto'), { recursive: true });
|
|
16
|
+
await cp(join(root, 'skills/orboto/scripts'), join(dir, 'skills/orboto/scripts'), { recursive: true });
|
|
17
|
+
const cache = join(dir, 'skills/orboto/.rules-hash');
|
|
18
|
+
await writeFile(cache, 'keep-hash\n');
|
|
19
|
+
let rules = '{}', next = '{}', status = 200, ruleReads = 0;
|
|
20
|
+
const server = createServer((req, res) => {
|
|
21
|
+
res.setHeader('Content-Type', 'application/json');
|
|
22
|
+
if (req.url?.startsWith('/agent-instructions')) {
|
|
23
|
+
ruleReads++;
|
|
24
|
+
res.statusCode = status;
|
|
25
|
+
res.end(rules);
|
|
26
|
+
}
|
|
27
|
+
else if (req.url?.startsWith('/work-sessions/next'))
|
|
28
|
+
res.end(next);
|
|
29
|
+
else if (req.url?.startsWith('/users/me/assigned-tickets'))
|
|
30
|
+
res.end('{"items":[]}');
|
|
31
|
+
else if (req.url?.startsWith('/users/me'))
|
|
32
|
+
res.end('{"id":"me","email":"fixture@example.test"}');
|
|
33
|
+
else
|
|
34
|
+
res.end('null');
|
|
35
|
+
});
|
|
36
|
+
await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
|
|
37
|
+
cleanup.push(() => new Promise((ok, no) => server.close((err) => err ? no(err) : ok())));
|
|
38
|
+
const address = server.address();
|
|
39
|
+
if (!address || typeof address === 'string')
|
|
40
|
+
throw new Error('fixture address missing');
|
|
41
|
+
const origin = `http://127.0.0.1:${address.port}`;
|
|
42
|
+
const guard = join(dir, 'fixture-network.mjs');
|
|
43
|
+
await writeFile(guard, `const original = globalThis.fetch; globalThis.fetch = (input, init) => { if (new URL(input).origin !== ${JSON.stringify(origin)}) throw new Error('Fixture refused a nonlocal request'); return original(input, init); };`);
|
|
44
|
+
const run = (args = ['session-start', '--force-rules']) => new Promise((ok, no) => {
|
|
45
|
+
const child = spawn(process.execPath, ['--import', guard, join(dir, 'skills/orboto/scripts/orboto.mjs'), ...args], {
|
|
46
|
+
cwd: dir, env: { ...process.env, ORBOTO_PROFILE: '', ORBOTO_BASE_URL: `http://127.0.0.1:${address.port}`, ORBOTO_TOKEN: 'orb_fixture', ORBOTO_DAEMON: '', ORBOTO_AGENT_SESSION: 'fixture' },
|
|
47
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
48
|
+
});
|
|
49
|
+
let out = '', err = '';
|
|
50
|
+
child.stdout.on('data', (chunk) => { out += chunk.toString(); });
|
|
51
|
+
child.stderr.on('data', (chunk) => { err += chunk.toString(); });
|
|
52
|
+
child.on('error', no);
|
|
53
|
+
child.on('close', (code) => ok({ code, out, err }));
|
|
54
|
+
});
|
|
55
|
+
return { run, cache, ruleReads: () => ruleReads, set: (body, code = 200) => { rules = body; status = code; }, setNext: (body) => { next = body; } };
|
|
56
|
+
}
|
|
57
|
+
describe('wrapper required rules through the real CLI', () => {
|
|
58
|
+
it('does not turn malformed work-next responses into an idle result', async () => {
|
|
59
|
+
const f = await fixture();
|
|
60
|
+
for (const body of ['{}', 'null', '[]', '{"reserved":null}', '{"reserved":""}', '<html>unavailable</html>']) {
|
|
61
|
+
f.setNext(body);
|
|
62
|
+
const result = await f.run(['work-next', 'ORB']);
|
|
63
|
+
expect(result.code).toBe(1);
|
|
64
|
+
expect(result.out).not.toContain('Nothing ready');
|
|
65
|
+
expect(await readFile(f.cache, 'utf8')).toBe('keep-hash\n');
|
|
66
|
+
}
|
|
67
|
+
f.setNext('{"reserved":null,"reason":"none-matching","retryAfterSeconds":null,"earliestFreeAt":null,"candidatesConsidered":0,"landedIdle":[]}');
|
|
68
|
+
expect((await f.run(['work-next', 'ORB'])).code).toBe(3);
|
|
69
|
+
f.setNext(JSON.stringify({ reserved: {
|
|
70
|
+
session: { id: 'session', role: 'implementation', leaseUntil: 'later' },
|
|
71
|
+
ticket: { ticketKey: 'ORB-42', title: 'fixture' }, reused: true,
|
|
72
|
+
rulesHash: 'keep-hash', rulesUnchanged: true,
|
|
73
|
+
primer: { markdown: '', totalTokens: 0 }, checklists: [], dependencies: { blocks: [], blockedBy: [] }, gitHealth: [], siblingSessions: [],
|
|
74
|
+
}, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1, landedIdle: [] }));
|
|
75
|
+
const ack = await f.run(['work-next', 'ORB']);
|
|
76
|
+
expect(ack.code).toBe(0);
|
|
77
|
+
expect(ack.out).toContain('not currently in your context');
|
|
78
|
+
expect(ack.out).toContain('session-start --force-rules');
|
|
79
|
+
expect(ack.out).not.toContain('keep following what you already loaded');
|
|
80
|
+
});
|
|
81
|
+
it('does not silently omit an explicitly requested init rules snapshot', async () => {
|
|
82
|
+
const f = await fixture();
|
|
83
|
+
f.set('{}');
|
|
84
|
+
const result = await f.run(['init', '--with-rules']);
|
|
85
|
+
expect(result.code).toBe(1);
|
|
86
|
+
expect(result.err).toContain('Required agent rules');
|
|
87
|
+
expect(f.ruleReads()).toBe(1);
|
|
88
|
+
});
|
|
89
|
+
it('fails closed without poisoning the cache, then recovers with a valid empty ruleset', async () => {
|
|
90
|
+
const f = await fixture();
|
|
91
|
+
for (const body of ['{}', 'null', '[]', '{"instructions":', '<html>secret-token</html>', '{"instructions":"","rulesHash":null}', '{"rulesHash":"keep-hash","rulesUnchanged":true}']) {
|
|
92
|
+
f.set(body);
|
|
93
|
+
const result = await f.run();
|
|
94
|
+
expect(result.code).toBe(1);
|
|
95
|
+
expect(result.out).not.toContain('no workspace rules');
|
|
96
|
+
expect(result.err).toContain('Required agent rules');
|
|
97
|
+
expect(result.err).not.toContain('secret-token');
|
|
98
|
+
expect(await readFile(f.cache, 'utf8')).toBe('keep-hash\n');
|
|
99
|
+
}
|
|
100
|
+
for (const status of [401, 403, 500, 503]) {
|
|
101
|
+
f.set('{"error":"secret-token"}', status);
|
|
102
|
+
const result = await f.run();
|
|
103
|
+
expect(result.code).toBe(1);
|
|
104
|
+
expect(result.err).toContain(`HTTP ${status}`);
|
|
105
|
+
expect(result.err).not.toContain('secret-token');
|
|
106
|
+
}
|
|
107
|
+
f.set('{"instructions":"","rulesHash":"empty","requireSessionStart":true}');
|
|
108
|
+
const result = await f.run();
|
|
109
|
+
expect(result.code).toBe(0);
|
|
110
|
+
expect(result.out).toContain('no workspace rules configured');
|
|
111
|
+
expect((await readFile(f.cache, 'utf8')).trim()).toBe('empty');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { OrbotoApiError } from './orboto-client.js';
|
|
2
|
+
/** Fixed diagnostics only: neither rule text nor upstream bodies belong in logs. */
|
|
3
|
+
export class RequiredRulesError extends Error {
|
|
4
|
+
reason;
|
|
5
|
+
status;
|
|
6
|
+
errorKey = 'errors.agent_rules.unavailable';
|
|
7
|
+
constructor(reason, status) {
|
|
8
|
+
super(`Required agent rules could not be loaded (${reason}${status ? ` ${status}` : ''}). Do not proceed without the rules. Check connectivity and authentication, then retry session-start with forceRules.`);
|
|
9
|
+
this.reason = reason;
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.name = 'RequiredRulesError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function record(value) {
|
|
15
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
/** Validate receipts before caching their hash or claiming delivery succeeded. */
|
|
18
|
+
export function validateRulesReceipt(value, knownHash, textField = 'instructions') {
|
|
19
|
+
if (!record(value) || typeof value.rulesHash !== 'string' || !value.rulesHash
|
|
20
|
+
|| ('rulesUnchanged' in value && typeof value.rulesUnchanged !== 'boolean')) {
|
|
21
|
+
throw new RequiredRulesError('invalid_response');
|
|
22
|
+
}
|
|
23
|
+
if (value.rulesUnchanged === true) {
|
|
24
|
+
if (!knownHash || value.rulesHash !== knownHash)
|
|
25
|
+
throw new RequiredRulesError('unbound_ack');
|
|
26
|
+
}
|
|
27
|
+
else if (typeof value[textField] !== 'string') {
|
|
28
|
+
throw new RequiredRulesError('invalid_response');
|
|
29
|
+
}
|
|
30
|
+
if (('requireSessionStart' in value && typeof value.requireSessionStart !== 'boolean')
|
|
31
|
+
|| ('rulesChars' in value && (!Number.isInteger(value.rulesChars) || Number(value.rulesChars) < 0))
|
|
32
|
+
|| ('rulesIndex' in value && (!Array.isArray(value.rulesIndex) || value.rulesIndex.some((row) => !record(row)
|
|
33
|
+
|| typeof row.title !== 'string' || !Number.isInteger(row.chars) || Number(row.chars) < 0)))) {
|
|
34
|
+
throw new RequiredRulesError('invalid_response');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function validateNextWorkEnvelope(value, peek = false) {
|
|
38
|
+
const integer = (n) => Number.isInteger(n) && Number(n) >= 0;
|
|
39
|
+
const candidate = record(value) ? value.candidate : undefined;
|
|
40
|
+
const hasCandidate = candidate !== undefined && candidate !== null;
|
|
41
|
+
if (!record(value) || !('reserved' in value) || (value.reserved !== null && !record(value.reserved))
|
|
42
|
+
|| (peek && value.reserved !== null)
|
|
43
|
+
|| (hasCandidate && (!peek || value.reserved !== null || value.reason !== null
|
|
44
|
+
|| !record(candidate) || typeof candidate.ticketId !== 'string' || !candidate.ticketId
|
|
45
|
+
|| (candidate.ticketKey !== null && typeof candidate.ticketKey !== 'string')
|
|
46
|
+
|| typeof candidate.title !== 'string' || (candidate.priority !== null && typeof candidate.priority !== 'string')))
|
|
47
|
+
|| (value.reserved === null
|
|
48
|
+
? !hasCandidate && (typeof value.reason !== 'string' || !['all-blocked', 'all-leased', 'none-matching', 'autonomy_paused', 'lane_paused', 'lane_limit_reached'].includes(value.reason))
|
|
49
|
+
: value.reason !== null)
|
|
50
|
+
|| !integer(value.candidatesConsidered)
|
|
51
|
+
|| (value.retryAfterSeconds !== null && !integer(value.retryAfterSeconds))
|
|
52
|
+
|| (value.earliestFreeAt !== null && typeof value.earliestFreeAt !== 'string')
|
|
53
|
+
|| !Array.isArray(value.landedIdle)
|
|
54
|
+
|| value.landedIdle.some((row) => !record(row) || typeof row.ticketId !== 'string' || !row.ticketId
|
|
55
|
+
|| (row.ticketKey !== null && typeof row.ticketKey !== 'string') || typeof row.title !== 'string'
|
|
56
|
+
|| typeof row.lastActivityAt !== 'string' || !integer(row.idleWorkingDays) || !integer(row.commitCount))) {
|
|
57
|
+
throw new RequiredRulesError('invalid_response');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** One bounded, required read shared by session-start, resources and connect. */
|
|
61
|
+
export async function loadRequiredRules(client, path, knownHash) {
|
|
62
|
+
let value;
|
|
63
|
+
const controller = new AbortController();
|
|
64
|
+
let deadline;
|
|
65
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
66
|
+
deadline = setTimeout(() => {
|
|
67
|
+
controller.abort();
|
|
68
|
+
reject(new RequiredRulesError('timeout'));
|
|
69
|
+
}, 10_000);
|
|
70
|
+
});
|
|
71
|
+
try {
|
|
72
|
+
// Race the whole operation: OAuth token/keychain acquisition can stall
|
|
73
|
+
// before fetch starts and therefore cannot observe fetch's abort signal.
|
|
74
|
+
value = await Promise.race([client.get(path, { signal: controller.signal }), timeout]);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (error instanceof RequiredRulesError)
|
|
78
|
+
throw error;
|
|
79
|
+
if (error instanceof OrbotoApiError)
|
|
80
|
+
throw new RequiredRulesError('http', error.status);
|
|
81
|
+
if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError'))
|
|
82
|
+
throw new RequiredRulesError('timeout');
|
|
83
|
+
if (error instanceof SyntaxError)
|
|
84
|
+
throw new RequiredRulesError('invalid_response');
|
|
85
|
+
throw new RequiredRulesError('transport');
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
clearTimeout(deadline);
|
|
89
|
+
}
|
|
90
|
+
validateRulesReceipt(value, knownHash);
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { OrbotoApiError, OrbotoClient } from './orboto-client.js';
|
|
3
|
+
import { loadRequiredRules, RequiredRulesError, validateRulesReceipt } from './required-rules.js';
|
|
4
|
+
import { makeSessionStartHandler } from './tools/session-start.js';
|
|
5
|
+
import { makeWorkNextHandler } from './tools/work-sessions.js';
|
|
6
|
+
import { createNudgeState, SESSION_START_TOOL } from './session-nudge.js';
|
|
7
|
+
import { withMetrics } from './with-metrics.js';
|
|
8
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
9
|
+
const makeClient = () => new OrbotoClient({ baseUrl: 'https://fixture.invalid', apiKey: 'orb_fixture' });
|
|
10
|
+
const full = { instructions: ' exact rules\n', rulesHash: 'hash', requireSessionStart: true };
|
|
11
|
+
describe('required rule delivery', () => {
|
|
12
|
+
it.each([null, {}, [], { reserved: null }, { reserved: '' }, { reserved: null, reason: ['none-matching'] }])('does not turn an incomplete dispatch response into idle %#', async (body) => {
|
|
13
|
+
const client = makeClient();
|
|
14
|
+
vi.spyOn(client, 'post').mockResolvedValue(body);
|
|
15
|
+
await expect(makeWorkNextHandler(client)({ projectKey: 'ORB' })).rejects.toBeInstanceOf(RequiredRulesError);
|
|
16
|
+
});
|
|
17
|
+
it.each([null, [], {}, 'HTML secret', { rulesHash: 'hash' }, { instructions: '' },
|
|
18
|
+
{ instructions: null, rulesHash: 'hash' }, { ...full, rulesUnchanged: null },
|
|
19
|
+
{ ...full, rulesIndex: [{}] }, { ...full, rulesChars: -1 }, { ...full, requireSessionStart: 'false' },
|
|
20
|
+
])('rejects incomplete successful response %# without leaking it', async (body) => {
|
|
21
|
+
const client = makeClient();
|
|
22
|
+
vi.spyOn(client, 'get').mockResolvedValue(body);
|
|
23
|
+
await expect(loadRequiredRules(client, '/agent-instructions')).rejects.toMatchObject({ reason: 'invalid_response' });
|
|
24
|
+
});
|
|
25
|
+
it.each([new Error('orb_secret network detail'), new DOMException('secret', 'TimeoutError'),
|
|
26
|
+
new SyntaxError('secret invalid JSON'), ...[401, 403, 500, 503].map((s) => new OrbotoApiError(s, 'secret body', 'https://secret.invalid')),
|
|
27
|
+
])('reports transport/auth/server failures safely %#', async (failure) => {
|
|
28
|
+
const client = makeClient();
|
|
29
|
+
vi.spyOn(client, 'get').mockRejectedValue(failure);
|
|
30
|
+
const error = await loadRequiredRules(client, '/agent-instructions').catch((e) => e);
|
|
31
|
+
expect(error).toBeInstanceOf(RequiredRulesError);
|
|
32
|
+
expect(String(error)).not.toContain('secret');
|
|
33
|
+
expect(String(error).length).toBeLessThan(512);
|
|
34
|
+
});
|
|
35
|
+
it('preserves exact full text, valid empty rules and bound acknowledgements', async () => {
|
|
36
|
+
const client = makeClient();
|
|
37
|
+
const get = vi.spyOn(client, 'get').mockResolvedValueOnce(full).mockResolvedValueOnce({ ...full, instructions: '' });
|
|
38
|
+
expect(await loadRequiredRules(client, '/agent-instructions')).toEqual(full);
|
|
39
|
+
expect((await loadRequiredRules(client, '/agent-instructions')).instructions).toBe('');
|
|
40
|
+
expect(get.mock.calls[0][1]?.signal).toBeInstanceOf(AbortSignal);
|
|
41
|
+
expect(() => validateRulesReceipt({ rulesHash: 'hash', rulesUnchanged: true }, 'hash')).not.toThrow();
|
|
42
|
+
expect(() => validateRulesReceipt({ rulesHash: 'hash', rulesUnchanged: true })).toThrow(RequiredRulesError);
|
|
43
|
+
expect(() => validateRulesReceipt({ rulesHash: 'other', rulesUnchanged: true }, 'hash')).toThrow(RequiredRulesError);
|
|
44
|
+
});
|
|
45
|
+
it.each([false, true])('failed session-start rulesOnly=%s stays gated and recovery succeeds without a poisoned ack', async (rulesOnly) => {
|
|
46
|
+
const client = makeClient();
|
|
47
|
+
let broken = true;
|
|
48
|
+
const paths = [];
|
|
49
|
+
vi.spyOn(client, 'post').mockResolvedValue({});
|
|
50
|
+
vi.spyOn(client, 'get').mockImplementation(async (path) => {
|
|
51
|
+
if (path.startsWith('/agent-instructions')) {
|
|
52
|
+
paths.push(path);
|
|
53
|
+
if (broken)
|
|
54
|
+
throw new Error('secret transport');
|
|
55
|
+
return full;
|
|
56
|
+
}
|
|
57
|
+
if (path.includes('assigned-tickets'))
|
|
58
|
+
return { items: [] };
|
|
59
|
+
if (path.includes('/messages'))
|
|
60
|
+
return { messages: [] };
|
|
61
|
+
return null;
|
|
62
|
+
});
|
|
63
|
+
const state = createNudgeState(true);
|
|
64
|
+
const session = withMetrics(client, SESSION_START_TOOL, undefined, makeSessionStartHandler(client), state);
|
|
65
|
+
const write = vi.fn(async () => ({ content: [] }));
|
|
66
|
+
const other = withMetrics(client, 'orboto_create_ticket', undefined, write, state);
|
|
67
|
+
const failed = await session({ rulesOnly, forceRules: true });
|
|
68
|
+
expect(failed.isError).toBe(true);
|
|
69
|
+
expect(failed.structuredContent).toMatchObject({ errorKey: 'errors.agent_rules.unavailable' });
|
|
70
|
+
expect(state.sessionStartRan).toBe(false);
|
|
71
|
+
expect((await other({})).isError).toBe(true);
|
|
72
|
+
expect(write).not.toHaveBeenCalled();
|
|
73
|
+
broken = false;
|
|
74
|
+
expect((await session({ rulesOnly, forceRules: true })).isError).not.toBe(true);
|
|
75
|
+
expect(state.sessionStartRan).toBe(true);
|
|
76
|
+
await other({});
|
|
77
|
+
expect(write).toHaveBeenCalledOnce();
|
|
78
|
+
expect(paths.every((path) => !path.includes('knownRulesHash'))).toBe(true);
|
|
79
|
+
broken = true;
|
|
80
|
+
expect((await session({ rulesOnly, forceRules: true })).isError).toBe(true);
|
|
81
|
+
expect(state.sessionStartRan).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
it.each(['token', 'refresh', 'fetch'])('bounds the complete operation including a stalled %s', async (stage) => {
|
|
84
|
+
vi.useFakeTimers();
|
|
85
|
+
try {
|
|
86
|
+
const never = () => new Promise(() => { });
|
|
87
|
+
const client = new OrbotoClient({ baseUrl: 'https://fixture.invalid', tokenProvider: {
|
|
88
|
+
getAccessToken: stage === 'token' ? never : async () => 'fixture',
|
|
89
|
+
forceRefresh: never,
|
|
90
|
+
} });
|
|
91
|
+
let signal;
|
|
92
|
+
const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
93
|
+
signal = init?.signal;
|
|
94
|
+
if (stage === 'refresh')
|
|
95
|
+
return new Response('', { status: 401 });
|
|
96
|
+
return new Promise(() => { });
|
|
97
|
+
});
|
|
98
|
+
const rejected = expect(loadRequiredRules(client, '/agent-instructions')).rejects.toMatchObject({ reason: 'timeout' });
|
|
99
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
100
|
+
await rejected;
|
|
101
|
+
if (stage === 'token')
|
|
102
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
103
|
+
else
|
|
104
|
+
expect(signal?.aborted).toBe(true);
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
vi.useRealTimers();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
});
|
package/dist/resources.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
28
28
|
import { OrbotoApiError } from './orboto-client.js';
|
|
29
|
+
import { loadRequiredRules } from './required-rules.js';
|
|
29
30
|
import { resolveProjectByKey, resolveTicketByKey } from './tools/shared.js';
|
|
30
31
|
export function registerOrbotoResources(server, client) {
|
|
31
32
|
// -------------------------------------------------------------------------
|
|
@@ -41,8 +42,8 @@ export function registerOrbotoResources(server, client) {
|
|
|
41
42
|
description: 'The complete, assembled binding rules you must follow as an agent in this workspace - cap-independent (the MCP instructions block may be truncated; this resource is not). orboto_session_start returns the same rules plus your in-progress work.',
|
|
42
43
|
mimeType: 'text/markdown',
|
|
43
44
|
}, async (uri) => {
|
|
44
|
-
const res = await client
|
|
45
|
-
const rules = res
|
|
45
|
+
const res = await loadRequiredRules(client, '/agent-instructions');
|
|
46
|
+
const rules = res.instructions || '(no workspace rules configured)';
|
|
46
47
|
return {
|
|
47
48
|
contents: [{ uri: uri.href, mimeType: 'text/markdown', text: `# orboto workspace agent rules\n\n${rules}` }],
|
|
48
49
|
};
|
package/dist/resources.test.js
CHANGED
|
@@ -171,8 +171,18 @@ describe('orboto:// search resource', () => {
|
|
|
171
171
|
});
|
|
172
172
|
});
|
|
173
173
|
describe('orboto:// rules resource (ORB-1177)', () => {
|
|
174
|
+
it.each([null, {}, [], { instructions: '' }])('refuses unavailable or malformed rules %#', async (body) => {
|
|
175
|
+
stub([{ json: body }]);
|
|
176
|
+
const handler = getTemplateHandler(buildServerWithResources(), 'rules');
|
|
177
|
+
await expect(handler(new URL('orboto://rules'), {})).rejects.toThrow('Required agent rules');
|
|
178
|
+
});
|
|
179
|
+
it('refuses a failed fetch instead of returning an empty placeholder', async () => {
|
|
180
|
+
stub([{ ok: false, status: 503 }]);
|
|
181
|
+
const handler = getTemplateHandler(buildServerWithResources(), 'rules');
|
|
182
|
+
await expect(handler(new URL('orboto://rules'), {})).rejects.toThrow('Required agent rules');
|
|
183
|
+
});
|
|
174
184
|
it('returns the complete assembled workspace rules', async () => {
|
|
175
|
-
stub([{ json: { instructions: 'RULE 1: ticket-first.\nRULE 2: one commit per ticket.' } }]);
|
|
185
|
+
stub([{ json: { instructions: 'RULE 1: ticket-first.\nRULE 2: one commit per ticket.', rulesHash: 'fixture' } }]);
|
|
176
186
|
const server = buildServerWithResources();
|
|
177
187
|
const handler = getTemplateHandler(server, 'rules');
|
|
178
188
|
const out = await handler(new URL('orboto://rules'), {});
|
|
@@ -182,7 +192,7 @@ describe('orboto:// rules resource (ORB-1177)', () => {
|
|
|
182
192
|
expect(text).toContain('RULE 2: one commit per ticket.');
|
|
183
193
|
});
|
|
184
194
|
it('degrades to a placeholder when no rules are configured', async () => {
|
|
185
|
-
stub([{ json: { instructions: '' } }]);
|
|
195
|
+
stub([{ json: { instructions: '', rulesHash: 'empty' } }]);
|
|
186
196
|
const server = buildServerWithResources();
|
|
187
197
|
const handler = getTemplateHandler(server, 'rules');
|
|
188
198
|
const out = await handler(new URL('orboto://rules'), {});
|
package/dist/server.js
CHANGED
|
@@ -26,6 +26,7 @@ import { aiStatusToolConfig, makeAiStatusHandler } from './tools/ai-status.js';
|
|
|
26
26
|
import { embeddingStatusToolConfig, makeEmbeddingStatusHandler } from './tools/embedding-status.js';
|
|
27
27
|
import { aiUsageToolConfig, makeAiUsageHandler } from './tools/ai-usage.js';
|
|
28
28
|
import { sessionStartToolConfig, makeSessionStartHandler } from './tools/session-start.js';
|
|
29
|
+
import { loadRequiredRules } from './required-rules.js';
|
|
29
30
|
import { responseExpandToolConfig, makeResponseExpandHandler } from './tools/response-expand.js';
|
|
30
31
|
import { helpToolConfig, makeHelpHandler } from './tools/help.js';
|
|
31
32
|
import { apiSearchToolConfig, makeApiSearchHandler } from './tools/api-search.js';
|
|
@@ -171,14 +172,13 @@ export async function buildOrbotoMcpServer(opts) {
|
|
|
171
172
|
// ORB-1090 - fetch the workspace's configurable working-rules at
|
|
172
173
|
// connect so admin edits propagate to every new MCP session. The
|
|
173
174
|
// instructions block is the one place every MCP client reliably sees
|
|
174
|
-
// the rules (clients don't read the repo's CLAUDE.md).
|
|
175
|
-
//
|
|
176
|
-
// or the fetch fails.
|
|
175
|
+
// the rules (clients don't read the repo's CLAUDE.md). If unavailable,
|
|
176
|
+
// keep the connection usable for recovery but gate all other tools.
|
|
177
177
|
let workingRules = FALLBACK_WORKING_RULES;
|
|
178
178
|
// ORB-1471 - the same connect-time fetch also reports whether this
|
|
179
179
|
// workspace requires `orboto_session_start` before any other tool call
|
|
180
|
-
// (the hard gate).
|
|
181
|
-
let requireSessionStart =
|
|
180
|
+
// (the hard gate). Unknown policy must never silently disable the gate.
|
|
181
|
+
let requireSessionStart = true;
|
|
182
182
|
try {
|
|
183
183
|
// ORB-1753 - stdio deployments can declare their profile via
|
|
184
184
|
// ORBOTO_AGENT_KIND / ORBOTO_MODEL_TIER; api-key standing profiles
|
|
@@ -186,13 +186,13 @@ export async function buildOrbotoMcpServer(opts) {
|
|
|
186
186
|
const connectParams = new URLSearchParams();
|
|
187
187
|
applyAgentProfile(connectParams);
|
|
188
188
|
const connectQs = connectParams.toString();
|
|
189
|
-
const res = await client
|
|
189
|
+
const res = await loadRequiredRules(client, `/agent-instructions${connectQs ? `?${connectQs}` : ''}`);
|
|
190
190
|
if (res?.instructions?.trim())
|
|
191
191
|
workingRules = res.instructions.trim();
|
|
192
|
-
requireSessionStart = res
|
|
192
|
+
requireSessionStart = res.requireSessionStart ?? true;
|
|
193
193
|
}
|
|
194
194
|
catch {
|
|
195
|
-
|
|
195
|
+
workingRules = 'Workspace rules are unavailable. Built-in hints are not a substitute. Call orboto_session_start with forceRules and retry after connectivity or authentication recovers.';
|
|
196
196
|
}
|
|
197
197
|
const server = new McpServer({ name: 'orboto', version: VERSION }, {
|
|
198
198
|
// ORB-940 - advertise resources/subscribe so MCP-aware clients
|
package/dist/session-nudge.js
CHANGED
|
@@ -49,29 +49,32 @@ export const SESSION_START_GATE_MESSAGE = 'This workspace requires you to load i
|
|
|
49
49
|
'other action. Call `orboto_session_start` now - it returns the rules you ' +
|
|
50
50
|
'must follow plus your in-progress work - then retry this call.';
|
|
51
51
|
/**
|
|
52
|
-
*
|
|
52
|
+
* Advance the gate state for one dispatch and report whether this
|
|
53
53
|
* dispatch must be REFUSED (returned an instructive error without running the
|
|
54
54
|
* handler).
|
|
55
55
|
*
|
|
56
56
|
* Semantics when the gate is enabled: every tool call other than
|
|
57
57
|
* `orboto_session_start` is refused until `orboto_session_start` has run once
|
|
58
|
-
* this session.
|
|
58
|
+
* this session. A rule refresh locks the session until its result succeeds
|
|
59
59
|
* (and is itself never gated). When the gate is disabled, nothing is ever
|
|
60
60
|
* refused (returns false) - default behaviour is unchanged.
|
|
61
61
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
62
|
+
* The metrics wrapper records successful completion separately; dispatch
|
|
63
|
+
* alone is not evidence of rule delivery.
|
|
64
64
|
*/
|
|
65
65
|
export function shouldGate(state, toolName) {
|
|
66
66
|
if (toolName === SESSION_START_TOOL) {
|
|
67
|
-
|
|
68
|
-
state.sessionStartRan = true;
|
|
67
|
+
state.sessionStartRan = false;
|
|
69
68
|
return false;
|
|
70
69
|
}
|
|
71
70
|
if (!state.gateEnabled)
|
|
72
71
|
return false;
|
|
73
72
|
return !state.sessionStartRan;
|
|
74
73
|
}
|
|
74
|
+
export function recordSessionStartResult(state, toolName, success) {
|
|
75
|
+
if (toolName === SESSION_START_TOOL)
|
|
76
|
+
state.sessionStartRan = success;
|
|
77
|
+
}
|
|
75
78
|
/** ORB-1471 - the instructive refusal result the gate returns. */
|
|
76
79
|
export function gateResult() {
|
|
77
80
|
return {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* call is orboto_session_start, and never mutate structuredContent.
|
|
7
7
|
*/
|
|
8
8
|
import { describe, it, expect } from 'vitest';
|
|
9
|
-
import { SESSION_START_TOOL, SESSION_START_NUDGE, SESSION_START_GATE_MESSAGE, createNudgeState, shouldNudge, shouldGate, gateResult, prependNudge, } from './session-nudge.js';
|
|
9
|
+
import { SESSION_START_TOOL, SESSION_START_NUDGE, SESSION_START_GATE_MESSAGE, createNudgeState, shouldNudge, shouldGate, recordSessionStartResult, gateResult, prependNudge, } from './session-nudge.js';
|
|
10
10
|
describe('shouldNudge (ORB-1331)', () => {
|
|
11
11
|
it('nudges on the first tool call when it is not session_start, then never again', () => {
|
|
12
12
|
const state = createNudgeState();
|
|
@@ -44,15 +44,20 @@ describe('shouldGate (ORB-1471)', () => {
|
|
|
44
44
|
// Pre session_start: every other tool is gated.
|
|
45
45
|
expect(shouldGate(state, 'orboto_list_projects')).toBe(true);
|
|
46
46
|
expect(shouldGate(state, 'orboto_create_ticket')).toBe(true);
|
|
47
|
-
//
|
|
47
|
+
// Dispatch is not proof of delivery; only successful completion unlocks.
|
|
48
48
|
expect(shouldGate(state, SESSION_START_TOOL)).toBe(false);
|
|
49
|
+
expect(shouldGate(state, 'orboto_list_projects')).toBe(true);
|
|
50
|
+
recordSessionStartResult(state, SESSION_START_TOOL, true);
|
|
49
51
|
// Post session_start: everything passes.
|
|
50
52
|
expect(shouldGate(state, 'orboto_list_projects')).toBe(false);
|
|
51
53
|
expect(shouldGate(state, 'orboto_create_ticket')).toBe(false);
|
|
52
54
|
});
|
|
53
|
-
it('
|
|
55
|
+
it('a failed refresh keeps the gate closed and a successful retry unlocks', () => {
|
|
54
56
|
const state = createNudgeState(true);
|
|
55
57
|
expect(shouldGate(state, SESSION_START_TOOL)).toBe(false);
|
|
58
|
+
recordSessionStartResult(state, SESSION_START_TOOL, false);
|
|
59
|
+
expect(shouldGate(state, 'orboto_get_ticket')).toBe(true);
|
|
60
|
+
recordSessionStartResult(state, SESSION_START_TOOL, true);
|
|
56
61
|
expect(shouldGate(state, 'orboto_get_ticket')).toBe(false);
|
|
57
62
|
});
|
|
58
63
|
it('gateResult is an instructive isError result pointing at session_start', () => {
|
|
@@ -24,14 +24,14 @@ import { SESSION_START_NUDGE } from './session-nudge.js';
|
|
|
24
24
|
// Every API call the tools make resolves to a benign empty payload so
|
|
25
25
|
// the handlers succeed without a live backend. list_projects → [] →
|
|
26
26
|
// "no projects" text + valid structuredContent; session_start's sub-
|
|
27
|
-
//
|
|
27
|
+
// rules read is required and carries an explicit valid empty ruleset.
|
|
28
28
|
function mockApi() {
|
|
29
|
-
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ({
|
|
29
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => ({
|
|
30
30
|
ok: true,
|
|
31
31
|
status: 200,
|
|
32
32
|
statusText: 'OK',
|
|
33
33
|
headers: new Headers({ 'content-type': 'application/json' }),
|
|
34
|
-
json: async () => [],
|
|
34
|
+
json: async () => String(url).includes('/agent-instructions') ? { instructions: '', rulesHash: 'empty', requireSessionStart: false } : [],
|
|
35
35
|
text: async () => '[]',
|
|
36
36
|
}));
|
|
37
37
|
}
|
|
@@ -54,6 +54,24 @@ function firstText(res) {
|
|
|
54
54
|
return content[0]?.text ?? '';
|
|
55
55
|
}
|
|
56
56
|
describe('ORB-1331 - HTTP transport (per-session nudge)', () => {
|
|
57
|
+
it('keeps an unavailable connect policy gated until rules are loaded successfully', async () => {
|
|
58
|
+
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('secret offline'));
|
|
59
|
+
const client = await connectSession();
|
|
60
|
+
try {
|
|
61
|
+
const blocked = await client.callTool({ name: 'orboto_list_projects', arguments: {} });
|
|
62
|
+
expect(blocked.isError).toBe(true);
|
|
63
|
+
const failed = await client.callTool({ name: 'orboto_session_start', arguments: { rulesOnly: true } });
|
|
64
|
+
expect(failed.isError).toBe(true);
|
|
65
|
+
expect(firstText(failed)).not.toContain('secret');
|
|
66
|
+
mockApi();
|
|
67
|
+
const recovered = await client.callTool({ name: 'orboto_session_start', arguments: { rulesOnly: true } });
|
|
68
|
+
expect(recovered.isError).not.toBe(true);
|
|
69
|
+
expect((await client.callTool({ name: 'orboto_list_projects', arguments: {} })).isError).not.toBe(true);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await client.close();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
57
75
|
it('prepends the nudge exactly once when the first call is orboto_list_projects; the second call is clean', async () => {
|
|
58
76
|
const client = await connectSession();
|
|
59
77
|
const first = await client.callTool({ name: 'orboto_list_projects', arguments: {} });
|
|
@@ -41,6 +41,12 @@ import { buildOrbotoMcpServer } from './server.js';
|
|
|
41
41
|
* also just good UI hygiene - a title that long is a description. */
|
|
42
42
|
const TITLE_MAX = 64;
|
|
43
43
|
let tools;
|
|
44
|
+
function unsafeTakeoverTools(list) {
|
|
45
|
+
return list
|
|
46
|
+
.filter((tool) => Object.prototype.hasOwnProperty.call(tool.inputSchema?.properties ?? {}, 'takeover'))
|
|
47
|
+
.filter((tool) => tool.annotations?.readOnlyHint !== false || tool.annotations?.destructiveHint !== true)
|
|
48
|
+
.map((tool) => tool.name);
|
|
49
|
+
}
|
|
44
50
|
beforeAll(async () => {
|
|
45
51
|
// Port 1 is never listened on -> the connect-time /agent-instructions
|
|
46
52
|
// fetch fails immediately and the server falls back to its built-in
|
|
@@ -84,6 +90,22 @@ describe('MCP tool safety annotations (ORB-1669)', () => {
|
|
|
84
90
|
.map((t) => t.name);
|
|
85
91
|
expect(offenders, `Contradictory annotations on: ${offenders.join(', ')}`).toEqual([]);
|
|
86
92
|
});
|
|
93
|
+
it('every takeover-capable tool is a destructive write for all argument combinations', () => {
|
|
94
|
+
const capable = tools.filter((tool) => Object.prototype.hasOwnProperty.call(tool.inputSchema?.properties ?? {}, 'takeover'));
|
|
95
|
+
// Pin the actual siblings so losing schema discovery cannot pass vacuously.
|
|
96
|
+
expect(capable.map((tool) => tool.name)).toEqual(expect.arrayContaining(['orboto_work_start', 'orboto_work_session_start']));
|
|
97
|
+
expect(unsafeTakeoverTools(tools)).toEqual([]);
|
|
98
|
+
// Reservation without a takeover path is not an account-state takeover.
|
|
99
|
+
expect(tools.find((tool) => tool.name === 'orboto_work_next')?.annotations).toMatchObject({ readOnlyHint: false, destructiveHint: false });
|
|
100
|
+
});
|
|
101
|
+
it('the takeover ratchet catches missing, false and contradictory hints on future tools', () => {
|
|
102
|
+
const future = { name: 'future_takeover', inputSchema: { properties: { takeover: { type: 'boolean' } } } };
|
|
103
|
+
for (const annotations of [undefined, { readOnlyHint: false }, { readOnlyHint: false, destructiveHint: false }, { readOnlyHint: true, destructiveHint: true }]) {
|
|
104
|
+
expect(unsafeTakeoverTools([{ ...future, annotations }])).toEqual(['future_takeover']);
|
|
105
|
+
}
|
|
106
|
+
expect(unsafeTakeoverTools([{ ...future, annotations: { readOnlyHint: false, destructiveHint: true } }])).toEqual([]);
|
|
107
|
+
expect(unsafeTakeoverTools([{ name: 'ordinary_read', annotations: { readOnlyHint: true } }])).toEqual([]);
|
|
108
|
+
});
|
|
87
109
|
it(`every tool has a human-readable title of at most ${TITLE_MAX} characters`, () => {
|
|
88
110
|
const missing = tools.filter((t) => !(t.title ?? t.annotations?.title)).map((t) => t.name);
|
|
89
111
|
expect(missing, `Tools with no title: ${missing.join(', ')}`).toEqual([]);
|
package/dist/tool-docs.test.js
CHANGED
|
@@ -55,6 +55,7 @@ describe('orboto_help handler', () => {
|
|
|
55
55
|
});
|
|
56
56
|
describe('live manifest vs help registry (no guidance lost)', () => {
|
|
57
57
|
it('every tool in the full manifest has a one-line wire description and full docs behind orboto_help', async () => {
|
|
58
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ instructions: '', rulesHash: 'empty', requireSessionStart: false }), { status: 200 }));
|
|
58
59
|
const server = await buildOrbotoMcpServer({
|
|
59
60
|
baseUrl: 'https://orboto.example.com', apiKey: 'orb_test', toolset: 'full',
|
|
60
61
|
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Shared rendering vocabulary for session-start and work-session bundles.
|
|
2
|
+
export const GIT_HEALTH_REASON_TEXT = {
|
|
3
|
+
connection_inactive: 'connection is deactivated',
|
|
4
|
+
app_installation_suspended: 'GitHub App installation is suspended',
|
|
5
|
+
oauth_token_expired: 'OAuth token expired with no refresh path',
|
|
6
|
+
history_backfill_error: 'last history backfill failed',
|
|
7
|
+
awaiting_first_event: 'webhook installed but has never delivered an event',
|
|
8
|
+
outbound_unreachable: 'orboto cannot reach the provider',
|
|
9
|
+
delivery_failing: 'webhook deliveries from the provider are not arriving',
|
|
10
|
+
};
|
|
@@ -50,6 +50,8 @@
|
|
|
50
50
|
import { z } from 'zod';
|
|
51
51
|
import { resolveTicketByKey, applyAgentProfile } from './shared.js';
|
|
52
52
|
import { PROTECT_TEXT_META, storePayload } from '../response-budget.js';
|
|
53
|
+
import { loadRequiredRules } from '../required-rules.js';
|
|
54
|
+
import { GIT_HEALTH_REASON_TEXT } from './git-health-reasons.js';
|
|
53
55
|
export const sessionStartToolConfig = {
|
|
54
56
|
title: 'Load the rules you must follow + re-orient',
|
|
55
57
|
description: 'THE canonical way to LOAD the binding workspace rules you must follow as an agent. Run it as your FIRST action in a session and immediately AFTER any context compaction. Returns a one-line-per-rule INDEX of the binding working-rules plus their hash (or a compact "unchanged" ack on a repeat call within the same connection) - read the index, then call this tool again with `rulesOnly: true` to read the full text of the rules whenever you do not already hold that exact hash, and expand before acting on any rule whose title touches what you are about to do. Also returns your in-progress tickets - each flagged LANDED, IDLE when it has a linked commit but has not moved for days, i.e. finished work you never handed to review - your running timer, and a warning if a project\'s git connection looks unhealthy (commit ingestion may be stalled). Pass `ticketKey` to also get a one-shot bundle for that ticket: project primer, the full ticket with dependencies + checklists, that project\'s git health, and any other agent sessions currently on it - replacing several separate calls. (Do NOT use orboto_list_agent_instructions to read the rules - that tool MANAGES/edits rule blocks for admins; this one is what you read to know how to work.) Read-only; no side effects. '
|
|
@@ -72,18 +74,6 @@ export const sessionStartToolConfig = {
|
|
|
72
74
|
}).shape,
|
|
73
75
|
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
74
76
|
};
|
|
75
|
-
const GIT_HEALTH_REASON_TEXT = {
|
|
76
|
-
connection_inactive: 'connection is deactivated',
|
|
77
|
-
app_installation_suspended: 'GitHub App installation is suspended',
|
|
78
|
-
oauth_token_expired: 'OAuth token expired with no refresh path',
|
|
79
|
-
history_backfill_error: 'last history backfill failed',
|
|
80
|
-
// ORB-1638
|
|
81
|
-
awaiting_first_event: 'webhook installed but has never delivered an event',
|
|
82
|
-
outbound_unreachable: 'orboto cannot reach the provider',
|
|
83
|
-
// ORB-1785 - two derivations now: the provider reporting a failed
|
|
84
|
-
// delivery, and the probe inferring one from commits that never arrived.
|
|
85
|
-
delivery_failing: 'webhook deliveries from the provider are not arriving',
|
|
86
|
-
};
|
|
87
77
|
// Cap how many distinct projects we probe for git health - a session's
|
|
88
78
|
// in-progress work is capped at 20 tickets already, so this rarely
|
|
89
79
|
// exceeds a handful, but bound it defensively so a pathological account
|
|
@@ -241,10 +231,10 @@ export function makeSessionStartHandler(client) {
|
|
|
241
231
|
// rules from the API instead, costs one call, and skips the whole rest
|
|
242
232
|
// of the digest. Protected in both halves: it IS the rule text.
|
|
243
233
|
if (input.rulesOnly) {
|
|
244
|
-
const rules = await client
|
|
234
|
+
const rules = await loadRequiredRules(client, rulesPath);
|
|
245
235
|
if (rules.rulesHash)
|
|
246
236
|
lastKnownRulesHash = rules.rulesHash;
|
|
247
|
-
const text = rules.instructions
|
|
237
|
+
const text = rules.instructions ?? '';
|
|
248
238
|
return {
|
|
249
239
|
_meta: { [PROTECT_TEXT_META]: true },
|
|
250
240
|
content: [{
|
|
@@ -267,7 +257,7 @@ export function makeSessionStartHandler(client) {
|
|
|
267
257
|
}
|
|
268
258
|
const [me, rules, assigned, timer, inboxRaw] = await Promise.all([
|
|
269
259
|
client.get('/users/me').catch(() => null),
|
|
270
|
-
client.get(
|
|
260
|
+
loadRequiredRules(client, rulesPath, rulesParams.get('knownRulesHash') ?? undefined),
|
|
271
261
|
// ORB-1330 - a re-orientation briefing must only list OPEN work.
|
|
272
262
|
// Filter to in_progress + in_review so DONE tickets can't pose as
|
|
273
263
|
// "what you're working on" at the moment the agent has the least
|
|
@@ -282,8 +272,6 @@ export function makeSessionStartHandler(client) {
|
|
|
282
272
|
.catch(() => []),
|
|
283
273
|
]);
|
|
284
274
|
const pendingMessages = inboxRaw.map((m) => ({ id: m.id, fromUserId: m.fromUserId, kind: m.kind, subject: m.subject, createdAt: m.createdAt }));
|
|
285
|
-
if (rules.rulesHash)
|
|
286
|
-
lastKnownRulesHash = rules.rulesHash;
|
|
287
275
|
const tickets = Array.isArray(assigned) ? assigned : (assigned?.items ?? []);
|
|
288
276
|
// ORB-1605 - git-connection health for every project the caller has
|
|
289
277
|
// open work in right now. Cheap, computed, read-only (see
|
|
@@ -326,7 +314,7 @@ export function makeSessionStartHandler(client) {
|
|
|
326
314
|
// index - one line per rule + the hash + a handle; the default.
|
|
327
315
|
// full - the text inline (forceRules, or an API too old to send
|
|
328
316
|
// an index - never leave a caller without its rules).
|
|
329
|
-
const rulesText = rules.instructions
|
|
317
|
+
const rulesText = rules.instructions ?? '';
|
|
330
318
|
const rulesIndex = Array.isArray(rules.rulesIndex)
|
|
331
319
|
? rules.rulesIndex.map((e) => e?.title).filter((t) => typeof t === 'string' && t.length > 0)
|
|
332
320
|
: [];
|
|
@@ -391,7 +379,7 @@ export function makeSessionStartHandler(client) {
|
|
|
391
379
|
if (bundle)
|
|
392
380
|
lines.push(...bundle.lines);
|
|
393
381
|
lines.push('', 'Re-run this after any context compaction to re-sync.');
|
|
394
|
-
|
|
382
|
+
const result = {
|
|
395
383
|
// ORB-1818 - when the full rule text IS the payload, neither half
|
|
396
384
|
// may be cut; see PROTECT_TEXT_META. The index answer carries no
|
|
397
385
|
// rule text and is budgeted like any other response.
|
|
@@ -431,5 +419,7 @@ export function makeSessionStartHandler(client) {
|
|
|
431
419
|
...(bundle ? { ticketBundle: bundle.structured } : {}),
|
|
432
420
|
},
|
|
433
421
|
};
|
|
422
|
+
lastKnownRulesHash = rules.rulesHash;
|
|
423
|
+
return result;
|
|
434
424
|
};
|
|
435
425
|
}
|
|
@@ -44,7 +44,7 @@ describe('orboto_session_start (ORB-1093)', () => {
|
|
|
44
44
|
const calls = stubByPath({
|
|
45
45
|
'/users/me/assigned-tickets': { items: [{ ticketKey: 'ORB-42', title: 'Wire it up', statusName: 'In Progress' }] },
|
|
46
46
|
'/users/me': { email: 'dev@x.io', fullName: 'Dev', workspaceLocale: 'en' },
|
|
47
|
-
'/agent-instructions': { instructions: 'claim -> commit -> close' },
|
|
47
|
+
'/agent-instructions': { instructions: 'claim -> commit -> close', rulesHash: 'fixture' },
|
|
48
48
|
'/time/timer': { ticketId: 't1', ticketKey: 'ORB-42', startedAt: '2026-06-15T10:00:00Z' },
|
|
49
49
|
});
|
|
50
50
|
const res = await makeSessionStartHandler(client)();
|
|
@@ -65,7 +65,7 @@ describe('orboto_session_start (ORB-1093)', () => {
|
|
|
65
65
|
stubByPath({
|
|
66
66
|
'/users/me/assigned-tickets': { items: [] },
|
|
67
67
|
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
68
|
-
'/agent-instructions': { instructions: 'rules here' },
|
|
68
|
+
'/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
|
|
69
69
|
'/time/timer': {},
|
|
70
70
|
});
|
|
71
71
|
const res = await makeSessionStartHandler(client)();
|
|
@@ -82,7 +82,7 @@ describe('orboto_session_start (ORB-1093)', () => {
|
|
|
82
82
|
items: [{ ticketKey: 'ORB-42', title: 'Wire it up', statusName: 'In Review', projectId: 'proj-1' }],
|
|
83
83
|
},
|
|
84
84
|
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
85
|
-
'/agent-instructions': { instructions: 'rules here' },
|
|
85
|
+
'/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
|
|
86
86
|
'/time/timer': {},
|
|
87
87
|
'/projects/proj-1/git-health': {
|
|
88
88
|
connections: [{
|
|
@@ -109,7 +109,7 @@ describe('orboto_session_start (ORB-1093)', () => {
|
|
|
109
109
|
items: [{ ticketKey: 'ORB-42', title: 'Wire it up', statusName: 'In Review', projectId: 'proj-1' }],
|
|
110
110
|
},
|
|
111
111
|
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
112
|
-
'/agent-instructions': { instructions: 'rules here' },
|
|
112
|
+
'/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
|
|
113
113
|
'/time/timer': {},
|
|
114
114
|
'/projects/proj-1/git-health': {
|
|
115
115
|
connections: [{
|
|
@@ -298,7 +298,7 @@ describe('orboto_session_start - context cost (ORB-1697)', () => {
|
|
|
298
298
|
items: [{ ticketKey: 'ORB-42', title: 'Wire it up', statusName: 'In Review', projectId: 'proj-1' }],
|
|
299
299
|
},
|
|
300
300
|
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
301
|
-
'/agent-instructions': { instructions: 'rules here' },
|
|
301
|
+
'/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
|
|
302
302
|
'/time/timer': {},
|
|
303
303
|
'/projects/proj-1/git-health': {
|
|
304
304
|
connections: [{
|
|
@@ -332,7 +332,7 @@ describe('orboto_session_start - context cost (ORB-1697)', () => {
|
|
|
332
332
|
],
|
|
333
333
|
},
|
|
334
334
|
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
335
|
-
'/agent-instructions': { instructions: 'rules here' },
|
|
335
|
+
'/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
|
|
336
336
|
'/time/timer': {},
|
|
337
337
|
'/v1/agent/presence': [],
|
|
338
338
|
});
|
|
@@ -355,7 +355,7 @@ describe('orboto_session_start - context cost (ORB-1697)', () => {
|
|
|
355
355
|
],
|
|
356
356
|
},
|
|
357
357
|
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
358
|
-
'/agent-instructions': { instructions: 'rules here' },
|
|
358
|
+
'/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
|
|
359
359
|
'/time/timer': {},
|
|
360
360
|
});
|
|
361
361
|
const res = await makeSessionStartHandler(client)();
|
|
@@ -383,7 +383,7 @@ describe('orboto_session_start - a 200 with an unexpected body never kills the d
|
|
|
383
383
|
'/projects/proj-1/tickets/by-key/42': { id: 'tick-1', projectId: 'proj-1', ticketKey: 'ORB-42', title: 'Bug' },
|
|
384
384
|
'/users/me/assigned-tickets': { items: [] },
|
|
385
385
|
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
386
|
-
'/agent-instructions': { instructions: 'rules here' },
|
|
386
|
+
'/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
|
|
387
387
|
});
|
|
388
388
|
const res = await makeSessionStartHandler(client)({ ticketKey: 'ORB-42' });
|
|
389
389
|
const text = res.content[0].text;
|
|
@@ -428,19 +428,20 @@ export function makeDeleteTicketHandler(client) {
|
|
|
428
428
|
};
|
|
429
429
|
}
|
|
430
430
|
export const commentToolConfig = {
|
|
431
|
-
title: 'Post a comment
|
|
432
|
-
description: '
|
|
431
|
+
title: 'Post a comment',
|
|
432
|
+
description: 'Post Markdown. isInternal=true hides it from guests. Attachment drafts are claimed atomically with the comment; never use public ticket uploads for private comments.',
|
|
433
433
|
inputSchema: z.object({
|
|
434
434
|
ticketKey: z.string().min(3),
|
|
435
435
|
text: z.string().min(1),
|
|
436
|
-
isInternal: z.boolean().optional().describe('Default
|
|
436
|
+
isInternal: z.boolean().optional().describe('Default false: guests can read.'),
|
|
437
|
+
attachmentDraftIds: z.array(z.string().uuid()).max(20).optional().describe('Private /attachment-drafts/tickets/:ticketId uploads only.'),
|
|
437
438
|
}).shape,
|
|
438
439
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
439
440
|
};
|
|
440
441
|
export function makeCommentHandler(client) {
|
|
441
|
-
return async ({ ticketKey, text, isInternal }) => {
|
|
442
|
+
return async ({ ticketKey, text, isInternal, attachmentDraftIds }) => {
|
|
442
443
|
const ticket = await resolveTicketByKey(client, ticketKey);
|
|
443
|
-
const created = await client.post(`/tickets/${ticket.id}/comments`, { content: text, isInternal: isInternal ?? false });
|
|
444
|
+
const created = await client.post(`/tickets/${ticket.id}/comments`, { content: text, isInternal: isInternal ?? false, ...(attachmentDraftIds ? { attachmentDraftIds } : {}) });
|
|
444
445
|
return {
|
|
445
446
|
content: [{
|
|
446
447
|
type: 'text',
|
|
@@ -395,6 +395,12 @@ describe('orboto_delete_ticket', () => {
|
|
|
395
395
|
});
|
|
396
396
|
});
|
|
397
397
|
describe('orboto_comment', () => {
|
|
398
|
+
it('forwards private upload claims without dropping the internal flag', async () => {
|
|
399
|
+
const calls = stub([{ json: PROJ }, { json: TICKET }, { json: { id: 'c1', content: 'private image', isInternal: true, createdAt: 'now' } }]);
|
|
400
|
+
const ids = ['aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa'];
|
|
401
|
+
await makeCommentHandler(client)({ ticketKey: 'ACME-1', text: 'private image', isInternal: true, attachmentDraftIds: ids });
|
|
402
|
+
expect(calls[2].body).toEqual({ content: 'private image', isInternal: true, attachmentDraftIds: ids });
|
|
403
|
+
});
|
|
398
404
|
it('posts a regular (non-internal) comment by default', async () => {
|
|
399
405
|
const calls = stub([
|
|
400
406
|
{ json: PROJ },
|
package/dist/tools/work-next.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
import { mcpInstanceToken } from './shared.js';
|
|
9
|
+
import { validateNextWorkEnvelope, validateRulesReceipt } from '../required-rules.js';
|
|
9
10
|
import { GIT_HEALTH_REASON_TEXT, ResourceClaimShape } from './work-sessions-shared.js';
|
|
10
11
|
/** ORB-1799 - rendered identically on every exit (reserved, empty, paused):
|
|
11
12
|
* an agent that just pulled fresh work is exactly the one about to forget
|
|
@@ -61,6 +62,7 @@ export function makeWorkNextHandler(client) {
|
|
|
61
62
|
agentSessionToken: token,
|
|
62
63
|
...(lastKnownRulesHash ? { knownRulesHash: lastKnownRulesHash } : {}),
|
|
63
64
|
});
|
|
65
|
+
validateNextWorkEnvelope(res, args.peek);
|
|
64
66
|
if (!res.reserved) {
|
|
65
67
|
// ORB-1930 - a peek that found work: name it, reserve nothing.
|
|
66
68
|
if (args.peek && res.candidate) {
|
|
@@ -123,8 +125,7 @@ export function makeWorkNextHandler(client) {
|
|
|
123
125
|
};
|
|
124
126
|
}
|
|
125
127
|
const r = res.reserved;
|
|
126
|
-
|
|
127
|
-
lastKnownRulesHash = r.rulesHash;
|
|
128
|
+
validateRulesReceipt(r, lastKnownRulesHash, 'rules');
|
|
128
129
|
const lines = [
|
|
129
130
|
r.reused
|
|
130
131
|
? `Renewed your existing ${r.session.role} session on ${r.ticket.ticketKey ?? r.session.ticketId}.`
|
|
@@ -138,7 +139,7 @@ export function makeWorkNextHandler(client) {
|
|
|
138
139
|
}
|
|
139
140
|
lines.push('', '## Working rules');
|
|
140
141
|
if (r.rulesUnchanged) {
|
|
141
|
-
lines.push(`Unchanged since your last call on this connection (hash ${r.rulesHash})
|
|
142
|
+
lines.push(`Unchanged since your last call on this connection (hash ${r.rulesHash}). If these rules are not currently in your context, call orboto_session_start with rulesOnly: true before proceeding.`);
|
|
142
143
|
}
|
|
143
144
|
else {
|
|
144
145
|
lines.push(r.rules?.trim() || '(no workspace rules configured)');
|
|
@@ -179,6 +180,7 @@ export function makeWorkNextHandler(client) {
|
|
|
179
180
|
? '(none)'
|
|
180
181
|
: r.siblingSessions.map((s) => ` - [${s.role}] ${s.userFullName ?? s.userEmail ?? 'unknown'} - lease until ${s.leaseUntil}`).join('\n'));
|
|
181
182
|
lines.push(...landedIdleLines(res.landedIdle));
|
|
183
|
+
lastKnownRulesHash = r.rulesHash;
|
|
182
184
|
return {
|
|
183
185
|
content: [{ type: 'text', text: lines.join('\n') }],
|
|
184
186
|
structuredContent: {
|
|
@@ -65,8 +65,31 @@ const RESERVED = {
|
|
|
65
65
|
siblingSessions: [],
|
|
66
66
|
};
|
|
67
67
|
describe('orboto_work_next', () => {
|
|
68
|
+
const peekResponse = {
|
|
69
|
+
reserved: null, reason: null, retryAfterSeconds: null, earliestFreeAt: null,
|
|
70
|
+
candidatesConsidered: 1, landedIdle: [],
|
|
71
|
+
candidate: { ticketId: 't1', ticketKey: 'ACME-42', title: 'Ready', priority: null },
|
|
72
|
+
};
|
|
73
|
+
it('validates a peek candidate without claiming rules delivery or a reservation', async () => {
|
|
74
|
+
const calls = stub([{ json: peekResponse }, { json: peekResponse }]);
|
|
75
|
+
const handler = makeWorkNextHandler(client);
|
|
76
|
+
const result = await handler({ projectKey: 'ACME', peek: true });
|
|
77
|
+
expect(result.structuredContent).toMatchObject({ reserved: null, candidate: peekResponse.candidate });
|
|
78
|
+
expect(calls[0].body).toMatchObject({ peek: true });
|
|
79
|
+
expect(calls[0].body).not.toHaveProperty('knownRulesHash');
|
|
80
|
+
await expect(handler({ projectKey: 'ACME' })).rejects.toMatchObject({ reason: 'invalid_response' });
|
|
81
|
+
});
|
|
82
|
+
it.each([
|
|
83
|
+
{ candidate: {} }, { candidate: { ticketId: '', ticketKey: null, title: '', priority: null } },
|
|
84
|
+
{ candidate: { ticketId: 't1', title: 'Ready', priority: null } },
|
|
85
|
+
{ candidate: { ticketId: 't1', ticketKey: null, title: 42, priority: null } },
|
|
86
|
+
{ reason: 'none-matching' }, { reserved: RESERVED }, { candidatesConsidered: -1 },
|
|
87
|
+
])('rejects malformed or contradictory peek responses %#', async (patch) => {
|
|
88
|
+
stub([{ json: { ...peekResponse, ...patch } }]);
|
|
89
|
+
await expect(makeWorkNextHandler(client)({ projectKey: 'ACME', peek: true })).rejects.toMatchObject({ reason: 'invalid_response' });
|
|
90
|
+
});
|
|
68
91
|
it('reserves the winning candidate and renders the full bundle, sending projectKey straight through', async () => {
|
|
69
|
-
const calls = stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 3 } }]);
|
|
92
|
+
const calls = stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 3, landedIdle: [] } }]);
|
|
70
93
|
const res = await makeWorkNextHandler(client)({ projectKey: 'ACME' });
|
|
71
94
|
expect(calls).toHaveLength(1);
|
|
72
95
|
expect(calls[0].method).toBe('POST');
|
|
@@ -87,25 +110,28 @@ describe('orboto_work_next', () => {
|
|
|
87
110
|
});
|
|
88
111
|
it('caches the rules hash across calls and renders "Renewed" on a reused session', async () => {
|
|
89
112
|
const handler = makeWorkNextHandler(client);
|
|
90
|
-
stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1 } }]);
|
|
113
|
+
stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1, landedIdle: [] } }]);
|
|
91
114
|
await handler({ projectKey: 'ACME' });
|
|
92
115
|
const calls2 = stub([{
|
|
93
116
|
json: {
|
|
94
117
|
reserved: { ...RESERVED, reused: true, rulesUnchanged: true, rules: undefined },
|
|
95
|
-
reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1,
|
|
118
|
+
reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1, landedIdle: [],
|
|
96
119
|
},
|
|
97
120
|
}]);
|
|
98
121
|
const res2 = await handler({ projectKey: 'ACME' });
|
|
99
122
|
expect(calls2[0].body?.knownRulesHash).toBe('abc123');
|
|
100
123
|
const text2 = res2.content[0].text;
|
|
101
124
|
expect(text2).toContain('Unchanged since your last call');
|
|
125
|
+
expect(text2).toContain('not currently in your context');
|
|
126
|
+
expect(text2).toContain('rulesOnly: true');
|
|
127
|
+
expect(text2).not.toContain('keep following what you already loaded');
|
|
102
128
|
expect(text2).toContain('Renewed your existing');
|
|
103
129
|
});
|
|
104
130
|
it('renders a structured empty result with a retry hint (all-leased)', async () => {
|
|
105
131
|
stub([{
|
|
106
132
|
json: {
|
|
107
133
|
reserved: null, reason: 'all-leased', retryAfterSeconds: 420,
|
|
108
|
-
earliestFreeAt: '2026-07-24T10:15:00Z', candidatesConsidered: 4,
|
|
134
|
+
earliestFreeAt: '2026-07-24T10:15:00Z', candidatesConsidered: 4, landedIdle: [],
|
|
109
135
|
},
|
|
110
136
|
}]);
|
|
111
137
|
const res = await makeWorkNextHandler(client)({ projectKey: 'ACME' });
|
|
@@ -124,7 +150,7 @@ describe('orboto_work_next', () => {
|
|
|
124
150
|
stub([{
|
|
125
151
|
json: {
|
|
126
152
|
reserved: null, reason: 'none-matching', retryAfterSeconds: null,
|
|
127
|
-
earliestFreeAt: null, candidatesConsidered: 0,
|
|
153
|
+
earliestFreeAt: null, candidatesConsidered: 0, landedIdle: [],
|
|
128
154
|
},
|
|
129
155
|
}]);
|
|
130
156
|
const res = await makeWorkNextHandler(client)({ projectKey: 'ACME' });
|
|
@@ -134,7 +160,7 @@ describe('orboto_work_next', () => {
|
|
|
134
160
|
expect(text).not.toContain('Retry in');
|
|
135
161
|
});
|
|
136
162
|
it('forwards role, leaseSeconds, and resourceClaims to the API', async () => {
|
|
137
|
-
const calls = stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1 } }]);
|
|
163
|
+
const calls = stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1, landedIdle: [] } }]);
|
|
138
164
|
await makeWorkNextHandler(client)({
|
|
139
165
|
projectKey: 'ACME',
|
|
140
166
|
role: 'review',
|
|
@@ -14,9 +14,4 @@ export const ResourceClaimShape = {
|
|
|
14
14
|
/** Shared 409-body reader: the endpoint's body carries EITHER `holder`
|
|
15
15
|
* (a lease conflict) OR `claimConflicts` (a resource-claim conflict) -
|
|
16
16
|
* never both, since the lease is checked before claims are applied. */
|
|
17
|
-
export
|
|
18
|
-
connection_inactive: 'connection is deactivated',
|
|
19
|
-
app_installation_suspended: 'GitHub App installation is suspended',
|
|
20
|
-
oauth_token_expired: 'OAuth token expired with no refresh path',
|
|
21
|
-
history_backfill_error: 'last history backfill failed',
|
|
22
|
-
};
|
|
17
|
+
export { GIT_HEALTH_REASON_TEXT } from './git-health-reasons.js';
|
|
@@ -122,6 +122,9 @@ describe('orboto_work_start', () => {
|
|
|
122
122
|
expect(post2.body?.knownRulesHash).toBe('abc123');
|
|
123
123
|
const text2 = res2.content[0].text;
|
|
124
124
|
expect(text2).toContain('Unchanged since your last work-start');
|
|
125
|
+
expect(text2).toContain('not currently in your context');
|
|
126
|
+
expect(text2).toContain('rulesOnly: true');
|
|
127
|
+
expect(text2).not.toContain('keep following what you already loaded');
|
|
125
128
|
expect(text2).toContain('Renewed your existing');
|
|
126
129
|
});
|
|
127
130
|
it('surfaces the lease holder on a 409 instead of throwing', async () => {
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
import { z } from 'zod';
|
|
19
19
|
import { OrbotoApiError } from '../orboto-client.js';
|
|
20
20
|
import { mcpInstanceToken, resolveTicketByKey } from './shared.js';
|
|
21
|
+
import { validateRulesReceipt } from '../required-rules.js';
|
|
22
|
+
export { makeWorkNextHandler, workNextToolConfig } from './work-next.js';
|
|
21
23
|
import { GIT_HEALTH_REASON_TEXT, ResourceClaimShape, } from './work-sessions-shared.js';
|
|
22
24
|
function parseClaimConflicts(err) {
|
|
23
25
|
try {
|
|
@@ -68,7 +70,8 @@ export const workStartToolConfig = {
|
|
|
68
70
|
onConflict: z.enum(['reject', 'queue']).optional()
|
|
69
71
|
.describe('Only matters when `resourceClaims` is set. Default `reject`.'),
|
|
70
72
|
}).shape,
|
|
71
|
-
|
|
73
|
+
// takeover=true cancels another holder; hints describe the worst allowed call.
|
|
74
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
72
75
|
};
|
|
73
76
|
export function makeWorkStartHandler(client) {
|
|
74
77
|
// Per-connection rules-hash cache, mirrored from session-start.ts's
|
|
@@ -96,8 +99,7 @@ export function makeWorkStartHandler(client) {
|
|
|
96
99
|
agentSessionToken: token,
|
|
97
100
|
...(lastKnownRulesHash ? { knownRulesHash: lastKnownRulesHash } : {}),
|
|
98
101
|
});
|
|
99
|
-
|
|
100
|
-
lastKnownRulesHash = res.rulesHash;
|
|
102
|
+
validateRulesReceipt(res, lastKnownRulesHash, 'rules');
|
|
101
103
|
const lines = [
|
|
102
104
|
res.reused
|
|
103
105
|
? `Renewed your existing ${res.session.role} session on ${args.ticketKey}.`
|
|
@@ -114,7 +116,7 @@ export function makeWorkStartHandler(client) {
|
|
|
114
116
|
}
|
|
115
117
|
lines.push('', '## Working rules');
|
|
116
118
|
if (res.rulesUnchanged) {
|
|
117
|
-
lines.push(`Unchanged since your last work-start on this connection (hash ${res.rulesHash})
|
|
119
|
+
lines.push(`Unchanged since your last work-start on this connection (hash ${res.rulesHash}). If these rules are not currently in your context, call orboto_session_start with rulesOnly: true before proceeding.`);
|
|
118
120
|
}
|
|
119
121
|
else {
|
|
120
122
|
lines.push(res.rules?.trim() || '(no workspace rules configured)');
|
|
@@ -156,6 +158,7 @@ export function makeWorkStartHandler(client) {
|
|
|
156
158
|
lines.push(res.siblingSessions.length === 0
|
|
157
159
|
? '(none)'
|
|
158
160
|
: res.siblingSessions.map((s) => ` - [${s.role}] ${s.userFullName ?? s.userEmail ?? 'unknown'} - lease until ${s.leaseUntil}`).join('\n'));
|
|
161
|
+
lastKnownRulesHash = res.rulesHash;
|
|
159
162
|
return {
|
|
160
163
|
content: [{ type: 'text', text: lines.join('\n') }],
|
|
161
164
|
structuredContent: {
|
|
@@ -236,7 +239,8 @@ export const workSessionStartToolConfig = {
|
|
|
236
239
|
onConflict: z.enum(['reject', 'queue']).optional()
|
|
237
240
|
.describe('Only matters when `resourceClaims` is set. Default `reject`: a conflicting write claim fails the WHOLE call with the conflicting holder(s) named - if this call would have created a brand-new session, that session is rolled back rather than left holding the lease without its claims. `queue`: the conflicting claim is accepted as `state: "waiting"` instead of failing; it is promoted automatically once the conflict clears (release, finish, or the next orboto_work_sessions read).'),
|
|
238
241
|
}).shape,
|
|
239
|
-
|
|
242
|
+
// The optional takeover can displace someone else's active session.
|
|
243
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
240
244
|
};
|
|
241
245
|
export function makeWorkSessionStartHandler(client) {
|
|
242
246
|
return async (args, extra) => {
|
package/dist/with-metrics.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { OrbotoApiError } from './orboto-client.js';
|
|
2
|
-
import { shouldNudge, prependNudge, shouldGate, gateResult } from './session-nudge.js';
|
|
2
|
+
import { shouldNudge, prependNudge, shouldGate, gateResult, recordSessionStartResult } from './session-nudge.js';
|
|
3
|
+
import { RequiredRulesError } from './required-rules.js';
|
|
3
4
|
import { applyResponseBudget, TruncationBlockAdvertisedSchema } from './response-budget.js';
|
|
4
5
|
import { buildStrictInputSchema, isRawShape } from './input-schema.js';
|
|
5
6
|
import { captureToolDoc, summarizeToolDescription } from './tool-docs.js';
|
|
@@ -62,8 +63,7 @@ nudge) {
|
|
|
62
63
|
// regardless of its outcome. Applied to the returned result below.
|
|
63
64
|
const wantsNudge = nudge ? shouldNudge(nudge, toolName) : false;
|
|
64
65
|
// ORB-1471 - HARD session-start gate. When the workspace requires it,
|
|
65
|
-
// refuse every tool call until `orboto_session_start`
|
|
66
|
-
// flips the "ran" flag when the tool IS session_start). The gate message
|
|
66
|
+
// refuse every tool call until `orboto_session_start` succeeds. The gate message
|
|
67
67
|
// supersedes the soft nudge, so we return it directly without running the
|
|
68
68
|
// handler. Disabled by default => `shouldGate` returns false and nothing
|
|
69
69
|
// below changes.
|
|
@@ -92,6 +92,8 @@ nudge) {
|
|
|
92
92
|
// as success=false in the log so dashboards reflect actual
|
|
93
93
|
// user-visible failures.
|
|
94
94
|
const isError = result.isError === true;
|
|
95
|
+
if (nudge)
|
|
96
|
+
recordSessionStartResult(nudge, toolName, !isError);
|
|
95
97
|
void postLogEntry(client, {
|
|
96
98
|
toolName,
|
|
97
99
|
durationMs: Date.now() - start,
|
|
@@ -113,6 +115,12 @@ nudge) {
|
|
|
113
115
|
}
|
|
114
116
|
catch (err) {
|
|
115
117
|
const durationMs = Date.now() - start;
|
|
118
|
+
if (nudge)
|
|
119
|
+
recordSessionStartResult(nudge, toolName, false);
|
|
120
|
+
if (err instanceof RequiredRulesError) {
|
|
121
|
+
void postLogEntry(client, { toolName, durationMs, success: false, statusCode: err.status, errorMessage: err.message, clientHint });
|
|
122
|
+
return { isError: true, content: [{ type: 'text', text: err.message }], structuredContent: { errorKey: err.errorKey, reason: err.reason, ...(err.status ? { status: err.status } : {}) } };
|
|
123
|
+
}
|
|
116
124
|
// ORB-1174 - an OrbotoApiError carries a real HTTP status + message.
|
|
117
125
|
// Return it as a structured isError result so the agent + human see
|
|
118
126
|
// WHY (401 vs 404 vs 500) and can self-correct, instead of the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orboto/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.182.0",
|
|
4
4
|
"description": "Model Context Protocol server for orboto - exposes tickets, projects, milestones, time, docs, primer-facts, alerts and absences as MCP tools for Claude Desktop / Cursor / Copilot Chat / any MCP-aware AI client.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|