@orboto/mcp 0.222.2 → 0.223.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/agent-context.js +49 -0
- package/dist/agent-context.test.js +45 -0
- package/dist/inbox-channel-acknowledged.test.js +19 -0
- package/dist/inbox-channel.js +7 -2
- package/dist/orboto-client.js +14 -2
- package/dist/session-origin.js +17 -2
- package/dist/session-origin.test.js +12 -1
- package/dist/tail-labels.js +2 -0
- package/dist/tools/agent-instructions.js +8 -5
- package/dist/tools/agent-messages.js +7 -1
- package/dist/tools/primer-facts.js +1 -1
- package/dist/tools/project-readiness-schema.js +4 -1
- package/dist/tools/query.js +49 -6
- package/dist/tools/query.test.js +69 -0
- package/dist/tools/session-start.js +55 -18
- package/dist/tools/session-start.test.js +109 -12
- package/dist/tools/update-project.js +3 -1
- package/dist/tools/update-project.test.js +10 -0
- package/package.json +1 -1
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** ORB-2576 - the agent context of a stdio MCP server, read exactly as the CLI reads it (cli/internal/cmd/session_context.go). */
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
/** The session id Claude Code hands its stdio MCP servers; an HTTP server never sees it. */
|
|
6
|
+
export const CLAUDE_SESSION_ENV = 'CLAUDE_CODE_SESSION_ID';
|
|
7
|
+
/** The Claude Code hook events the CLI registers (claude_hook_settings.go OrbotoHookEvents). */
|
|
8
|
+
export const ORBOTO_HOOK_EVENTS = ['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'Stop', 'StopFailure', 'SessionStart', 'SubagentStart', 'SubagentStop', 'SessionEnd', 'Notification'];
|
|
9
|
+
/** The project settings file the hooks are registered in. */
|
|
10
|
+
export const PROJECT_CLAUDE_SETTINGS = '.claude/settings.local.json';
|
|
11
|
+
/** The turn-state file name the CLI hook writes for a session. */
|
|
12
|
+
export function turnStateName(sessionId) {
|
|
13
|
+
return `${sessionId.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 80) || 'session'}.json`;
|
|
14
|
+
}
|
|
15
|
+
/** The orboto hook events a checkout's settings lack; null when the file cannot be read. */
|
|
16
|
+
export function missingTurnHooks(dir) {
|
|
17
|
+
let hooks = {};
|
|
18
|
+
try {
|
|
19
|
+
hooks = JSON.parse(readFileSync(join(dir, PROJECT_CLAUDE_SETTINGS), 'utf8')).hooks ?? {};
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
if (err?.code !== 'ENOENT')
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const covered = (groups) => Array.isArray(groups) && groups.some((g) => Array.isArray(g?.hooks)
|
|
26
|
+
&& (g.hooks).some((h) => String(h?.command ?? '').includes('claude-hook')));
|
|
27
|
+
return ORBOTO_HOOK_EVENTS.filter((event) => !covered(hooks[event]));
|
|
28
|
+
}
|
|
29
|
+
/** The context key, or '' when no complete hook set confirms the current generation or a subagent shares the session. */
|
|
30
|
+
export function agentContextKey(env = process.env, home = homedir()) {
|
|
31
|
+
const id = (env[CLAUDE_SESSION_ENV] ?? '').trim();
|
|
32
|
+
if (!id)
|
|
33
|
+
return '';
|
|
34
|
+
let state;
|
|
35
|
+
try {
|
|
36
|
+
state = JSON.parse(readFileSync(join(home, '.orboto', 'claude-turn', turnStateName(id)), 'utf8'));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
if (!state.state || typeof state.contextStartedAt !== 'string' || !state.contextStartedAt)
|
|
42
|
+
return '';
|
|
43
|
+
if (Array.isArray(state.activeSubagents) && state.activeSubagents.length > 0)
|
|
44
|
+
return '';
|
|
45
|
+
const missing = typeof state.cwd === 'string' ? missingTurnHooks(state.cwd) : null;
|
|
46
|
+
if (!missing || missing.length > 0)
|
|
47
|
+
return '';
|
|
48
|
+
return `claude-code ${id} ${state.contextStartedAt}`;
|
|
49
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** ORB-2576 - a stdio server reads the agent context the CLI hook recorded; anything else is an unknown context. */
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { ORBOTO_HOOK_EVENTS, agentContextKey, missingTurnHooks, turnStateName } from './agent-context.js';
|
|
7
|
+
function hooks(dir, events = ORBOTO_HOOK_EVENTS) {
|
|
8
|
+
mkdirSync(join(dir, '.claude'), { recursive: true });
|
|
9
|
+
writeFileSync(join(dir, '.claude', 'settings.local.json'), JSON.stringify({ hooks: Object.fromEntries(events.map((e) => [e, [{ hooks: [{ type: 'command', command: 'orboto claude-hook' }] }]])) }));
|
|
10
|
+
}
|
|
11
|
+
function home(session, contextStartedAt, extra = {}) {
|
|
12
|
+
const dir = mkdtempSync(join(tmpdir(), 'orboto-mcp-context-'));
|
|
13
|
+
hooks(dir);
|
|
14
|
+
if (session) {
|
|
15
|
+
mkdirSync(join(dir, '.orboto', 'claude-turn'), { recursive: true });
|
|
16
|
+
writeFileSync(join(dir, '.orboto', 'claude-turn', turnStateName(session)), JSON.stringify({ sessionId: session, cwd: dir, state: 'idle', contextStartedAt, ...extra }));
|
|
17
|
+
}
|
|
18
|
+
return dir;
|
|
19
|
+
}
|
|
20
|
+
describe('agentContextKey (ORB-2576)', () => {
|
|
21
|
+
it('names the Claude Code session and the generation its SessionStart hook recorded', () => {
|
|
22
|
+
expect(agentContextKey({ CLAUDE_CODE_SESSION_ID: 'sess-a' }, home('sess-a', '2026-10-18T10:00:00Z'))).toBe('claude-code sess-a 2026-10-18T10:00:00Z');
|
|
23
|
+
});
|
|
24
|
+
it('is unknown without the session id, without the hook record, or without a generation', () => {
|
|
25
|
+
expect(agentContextKey({}, home('sess-a', '2026-10-18T10:00:00Z'))).toBe('');
|
|
26
|
+
expect(agentContextKey({ CLAUDE_CODE_SESSION_ID: 'sess-a' }, home())).toBe('');
|
|
27
|
+
expect(agentContextKey({ CLAUDE_CODE_SESSION_ID: 'sess-a' }, home('sess-a'))).toBe('');
|
|
28
|
+
});
|
|
29
|
+
it('a subagent inside a running session gets the rules in full: its session id is the parent\'s, so the context is unknown while it runs', () => {
|
|
30
|
+
expect(agentContextKey({ CLAUDE_CODE_SESSION_ID: 'sess-a' }, home('sess-a', '2026-10-18T10:00:00Z', { activeSubagents: ['agent-1'] }))).toBe('');
|
|
31
|
+
});
|
|
32
|
+
it('a hook set without the subagent events is an unknown context', () => {
|
|
33
|
+
const dir = home('sess-a', '2026-10-18T10:00:00Z');
|
|
34
|
+
hooks(dir, ORBOTO_HOOK_EVENTS.filter((e) => !e.startsWith('Subagent')));
|
|
35
|
+
expect(missingTurnHooks(dir)).toEqual(['SubagentStart', 'SubagentStop']);
|
|
36
|
+
expect(agentContextKey({ CLAUDE_CODE_SESSION_ID: 'sess-a' }, dir)).toBe('');
|
|
37
|
+
});
|
|
38
|
+
it('registers the same hook events as the CLI and names the turn file like it does', () => {
|
|
39
|
+
const go = readFileSync(new URL('../../../cli/internal/cmd/claude_hook.go', import.meta.url), 'utf8') + readFileSync(new URL('../../../cli/internal/cmd/claude_hook_settings.go', import.meta.url), 'utf8');
|
|
40
|
+
for (const event of ORBOTO_HOOK_EVENTS)
|
|
41
|
+
expect(go).toContain(`"${event}"`);
|
|
42
|
+
expect(turnStateName('a/b..c')).toBe('abc.json');
|
|
43
|
+
expect(turnStateName('///')).toBe('session.json');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** ORB-2564 - a person's acknowledgement wakes the sending session as kind `acknowledged`, and the inbox names the ladder step of urgent mail. */
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { eventKind, renderEvent } from './inbox-channel.js';
|
|
4
|
+
import { repeatMarks } from './tools/agent-messages.js';
|
|
5
|
+
describe('ORB-2564 - acknowledged wake', () => {
|
|
6
|
+
it('renders the acknowledgement as its own kind and any other message by its kind', () => {
|
|
7
|
+
const ack = { id: 'a1', kind: 'info', subject: 'acknowledged: disk full', payload: { type: 'acknowledged', acknowledgedMessageId: 'm1' }, createdAt: new Date().toISOString() };
|
|
8
|
+
expect(eventKind(ack)).toBe('acknowledged');
|
|
9
|
+
const event = renderEvent(ack, Date.now());
|
|
10
|
+
expect(event.meta.kind).toBe('acknowledged');
|
|
11
|
+
expect(event.content.startsWith('[acknowledged] acknowledged: disk full')).toBe(true);
|
|
12
|
+
expect(eventKind({ kind: 'request', payload: { type: 'ticket_ready' } })).toBe('request');
|
|
13
|
+
});
|
|
14
|
+
it('marks how far an urgent message escalated', () => {
|
|
15
|
+
expect(repeatMarks({ priority: 'urgent', escalationStep: 2 })).toBe(' [urgent, escalated: deputy]');
|
|
16
|
+
expect(repeatMarks({ priority: 'urgent', escalationStep: 1, reminderCount: 1 })).toBe(' [urgent, reminder 1, escalated: mail and channels]');
|
|
17
|
+
expect(repeatMarks({ priority: 'normal' })).toBe('');
|
|
18
|
+
});
|
|
19
|
+
});
|
package/dist/inbox-channel.js
CHANGED
|
@@ -91,14 +91,19 @@ export function isImmediate(m, now, digestMinutes) {
|
|
|
91
91
|
return true;
|
|
92
92
|
return ASK.test(`${m.subject} ${firstLine(m.payload)}`);
|
|
93
93
|
}
|
|
94
|
+
/** ORB-2564 - a person acknowledged a message this session sent: the event kind is `acknowledged`, so the sender stops reminding. */
|
|
95
|
+
export function eventKind(m) {
|
|
96
|
+
return m.payload?.type === 'acknowledged' ? 'acknowledged' : m.kind;
|
|
97
|
+
}
|
|
94
98
|
export function renderEvent(m, now) {
|
|
99
|
+
const kind = eventKind(m);
|
|
95
100
|
const from = m.from?.label ?? m.fromUserId ?? 'unknown';
|
|
96
101
|
const to = m.to?.label ?? 'you';
|
|
97
102
|
const text = firstLine(m.payload);
|
|
98
103
|
const body = text.length > CONTENT_CAP ? `${text.slice(0, CONTENT_CAP)} [... cut, read the rest with orboto_messages]` : text;
|
|
99
104
|
const marks = [m.priority === 'urgent' ? '[urgent]' : '', m.reminderCount ? `[reminder ${m.reminderCount}]` : ''].filter(Boolean).join(' ');
|
|
100
105
|
const lines = [
|
|
101
|
-
`${marks ? `${marks} ` : ''}[${
|
|
106
|
+
`${marks ? `${marks} ` : ''}[${kind}] ${m.subject}`,
|
|
102
107
|
`from ${from} to ${to}${m.projectKey ? ` | project ${m.projectKey}` : ''} | id ${m.id}${m.threadId ? ` | thread ${m.threadId}` : ''}`,
|
|
103
108
|
];
|
|
104
109
|
if (body)
|
|
@@ -110,7 +115,7 @@ export function renderEvent(m, now) {
|
|
|
110
115
|
return {
|
|
111
116
|
content: lines.join('\n'),
|
|
112
117
|
meta: {
|
|
113
|
-
id: m.id, kind
|
|
118
|
+
id: m.id, kind, project: m.projectKey ?? '',
|
|
114
119
|
from: from, from_session: (m.from?.sessionId ?? '').slice(0, 8), to_session: (m.to?.sessionId ?? '').slice(0, 8),
|
|
115
120
|
waited_min: String(waited),
|
|
116
121
|
...(m.priority === 'urgent' ? { priority: 'urgent' } : {}),
|
package/dist/orboto-client.js
CHANGED
|
@@ -4,15 +4,27 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { VERSION } from './version.js';
|
|
6
6
|
import { originHeader } from './session-origin.js';
|
|
7
|
+
/** ORB-2581 - the seconds a `Retry-After` header asks for (delta-seconds or an HTTP date); null when absent or unreadable. */
|
|
8
|
+
export function parseRetryAfter(value, now = Date.now()) {
|
|
9
|
+
if (value == null || value.trim() === '')
|
|
10
|
+
return null;
|
|
11
|
+
const trimmed = value.trim();
|
|
12
|
+
if (/^\d+$/.test(trimmed))
|
|
13
|
+
return Number(trimmed);
|
|
14
|
+
const at = Date.parse(trimmed);
|
|
15
|
+
return Number.isNaN(at) ? null : Math.max(0, Math.ceil((at - now) / 1000));
|
|
16
|
+
}
|
|
7
17
|
export class OrbotoApiError extends Error {
|
|
8
18
|
status;
|
|
9
19
|
body;
|
|
10
20
|
url;
|
|
11
|
-
|
|
21
|
+
retryAfterSeconds;
|
|
22
|
+
constructor(status, body, url, retryAfterSeconds = null) {
|
|
12
23
|
super(`orboto API ${status}: ${body || '(empty body)'}`);
|
|
13
24
|
this.status = status;
|
|
14
25
|
this.body = body;
|
|
15
26
|
this.url = url;
|
|
27
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
16
28
|
this.name = 'OrbotoApiError';
|
|
17
29
|
}
|
|
18
30
|
}
|
|
@@ -79,7 +91,7 @@ export class OrbotoClient {
|
|
|
79
91
|
this.pendingAgentMail = mail ? Number(mail) || 0 : 0;
|
|
80
92
|
if (!res.ok) {
|
|
81
93
|
const body = await res.text().catch(() => '');
|
|
82
|
-
throw new OrbotoApiError(res.status, body, url);
|
|
94
|
+
throw new OrbotoApiError(res.status, body, url, parseRetryAfter(res.headers?.get?.('retry-after')));
|
|
83
95
|
}
|
|
84
96
|
return res;
|
|
85
97
|
}
|
package/dist/session-origin.js
CHANGED
|
@@ -11,8 +11,22 @@ import { hostname } from 'node:os';
|
|
|
11
11
|
import { basename, dirname, join } from 'node:path';
|
|
12
12
|
import { VERSION } from './version.js';
|
|
13
13
|
const KEYS = [
|
|
14
|
-
['host', 'host'], ['machine', 'machineId'], ['repo', 'repo'], ['pid', 'pid'], ['start', 'startedAt'], ['client', 'client'], ['version', 'clientVersion'], ['harness', 'harness'],
|
|
14
|
+
['host', 'host'], ['machine', 'machineId'], ['repo', 'repo'], ['pid', 'pid'], ['start', 'startedAt'], ['client', 'client'], ['version', 'clientVersion'], ['harness', 'harness'], ['model', 'model'],
|
|
15
15
|
];
|
|
16
|
+
const WRAPPED_HARNESSES = { claude: 'claude-code', 'claude-code': 'claude-code', codex: 'codex', gemini: 'gemini', kimi: 'kimi', agy: 'agy', antigravity: 'agy' };
|
|
17
|
+
/** ORB-2571 - the harness that started a stdio proxy: the one `orboto <harness>` named, else the markers a harness sets. */
|
|
18
|
+
export function stdioHarness(env = process.env) {
|
|
19
|
+
const wrapped = WRAPPED_HARNESSES[(env.ORBOTO_HARNESS ?? '').trim().toLowerCase()];
|
|
20
|
+
if (wrapped)
|
|
21
|
+
return wrapped;
|
|
22
|
+
if (env.CLAUDECODE)
|
|
23
|
+
return 'claude-code';
|
|
24
|
+
if (env.CODEX_THREAD_ID || env.CODEX_SESSION_ID)
|
|
25
|
+
return 'codex';
|
|
26
|
+
if (env.GEMINI_CLI)
|
|
27
|
+
return 'gemini';
|
|
28
|
+
return 'mcp-stdio';
|
|
29
|
+
}
|
|
16
30
|
/** The header value; empty fields are left out. */
|
|
17
31
|
export function formatOrigin(origin) {
|
|
18
32
|
const params = new URLSearchParams();
|
|
@@ -75,7 +89,8 @@ export function stdioOrigin(env = process.env) {
|
|
|
75
89
|
return formatOrigin({
|
|
76
90
|
host: shortHost(), machineId: machineId(), repo: repoBasename(), pid: process.ppid || null,
|
|
77
91
|
client: 'orboto-mcp', clientVersion: VERSION,
|
|
78
|
-
harness: env
|
|
92
|
+
harness: stdioHarness(env),
|
|
93
|
+
model: /^[\x21-\x7e]{1,80}$/.test((env.ORBOTO_MODEL ?? '').trim()) ? env.ORBOTO_MODEL.trim() : null,
|
|
79
94
|
});
|
|
80
95
|
}
|
|
81
96
|
/** The HTTP server's origin: the server process itself, the identity of every connection is its OAuth client. */
|
|
@@ -7,7 +7,7 @@ import { mkdtempSync, mkdirSync } from 'node:fs';
|
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { createHttpServer } from './http-transport.js';
|
|
10
|
-
import { formatOrigin, machineId, repoBasename, shortHost, stdioOrigin } from './session-origin.js';
|
|
10
|
+
import { formatOrigin, machineId, repoBasename, shortHost, stdioHarness, stdioOrigin } from './session-origin.js';
|
|
11
11
|
import { bindSessionInstance, mcpInstanceToken, releaseSessionInstance, stableClientInstance } from './tools/shared.js';
|
|
12
12
|
import { InboxChannel, SESSION_ENDED_NOTICE } from './inbox-channel.js';
|
|
13
13
|
describe('ORB-2544 - the origin header', () => {
|
|
@@ -20,6 +20,17 @@ describe('ORB-2544 - the origin header', () => {
|
|
|
20
20
|
expect(params.get('harness')).toBe('claude-code');
|
|
21
21
|
expect(params.get('client')).toBe('orboto-mcp');
|
|
22
22
|
});
|
|
23
|
+
it('ORB-2571 - a stdio proxy names the harness orboto <harness> started and its model, else the harness markers', () => {
|
|
24
|
+
expect(stdioHarness({ ORBOTO_HARNESS: 'kimi', CLAUDECODE: '1' })).toBe('kimi');
|
|
25
|
+
expect(stdioHarness({ ORBOTO_HARNESS: 'antigravity' })).toBe('agy');
|
|
26
|
+
expect(stdioHarness({ GEMINI_CLI: '1' })).toBe('gemini');
|
|
27
|
+
expect(stdioHarness({ CODEX_SESSION_ID: 's' })).toBe('codex');
|
|
28
|
+
expect(stdioHarness({})).toBe('mcp-stdio');
|
|
29
|
+
const params = new URLSearchParams(stdioOrigin({ ORBOTO_HARNESS: 'agy', ORBOTO_MODEL: 'gemini-4-argon' }));
|
|
30
|
+
expect(params.get('harness')).toBe('agy');
|
|
31
|
+
expect(params.get('model')).toBe('gemini-4-argon');
|
|
32
|
+
expect(new URLSearchParams(stdioOrigin({ ORBOTO_MODEL: 'has spaces' })).has('model')).toBe(false);
|
|
33
|
+
});
|
|
23
34
|
it('reduces the host to its first label and a checkout to the repository basename', () => {
|
|
24
35
|
expect(shortHost('Build-Host.example.lan.')).toBe('build-host');
|
|
25
36
|
const root = mkdtempSync(join(tmpdir(), 'origin-'));
|
package/dist/tail-labels.js
CHANGED
|
@@ -36,6 +36,8 @@ export const TAIL_LABELS = [
|
|
|
36
36
|
{ method: 'GET', path: new RegExp(`^/v1/agent/messages/${SEG}/attachments(/${SEG}/url)?$`), label: 'orboto_agent_message_attachments' },
|
|
37
37
|
{ method: 'GET', path: /^\/admin\/agents\/transfers$/, label: 'orboto_agent_transfers' },
|
|
38
38
|
{ method: 'DELETE', path: new RegExp(`^/admin/agents/transfers/${SEG}$`), label: 'orboto_agent_transfers' },
|
|
39
|
+
{ method: 'GET', path: new RegExp(`^/admin/agents/identities/${SEG}/query-rate-limit$`), label: 'orboto_agent_query_rate_limit' },
|
|
40
|
+
{ method: 'PUT', path: new RegExp(`^/admin/agents/identities/${SEG}/query-rate-limit$`), label: 'orboto_agent_query_rate_limit' },
|
|
39
41
|
];
|
|
40
42
|
/** The metrics label of one tool call: the stand-in tool name for a labelled api_call, else the tool name itself. */
|
|
41
43
|
export function metricsToolName(toolName, args) {
|
|
@@ -12,7 +12,7 @@ function excerptOf(body) {
|
|
|
12
12
|
return firstLine.length > 200 ? `${firstLine.slice(0, 199)}\u2026` : firstLine;
|
|
13
13
|
}
|
|
14
14
|
function renderBlock(b) {
|
|
15
|
-
const flag = b.enabled ? '' : ' [disabled]';
|
|
15
|
+
const flag = (b.enabled ? '' : ' [disabled]') + (b.mustRead ? ' [must read]' : '');
|
|
16
16
|
const kind = b.builtinKey ? `default:${b.builtinKey}` : 'custom';
|
|
17
17
|
return `- ${b.title}${flag} (${kind}, ${b.body.length} chars, id ${b.id})\n ${excerptOf(b.body)}`;
|
|
18
18
|
}
|
|
@@ -62,6 +62,7 @@ export function makeListAgentInstructionsHandler(client) {
|
|
|
62
62
|
id: b.id,
|
|
63
63
|
title: b.title,
|
|
64
64
|
enabled: b.enabled,
|
|
65
|
+
...(b.mustRead ? { mustRead: true } : {}),
|
|
65
66
|
contentChars: b.body.length,
|
|
66
67
|
...(b.builtinKey ? { builtinKey: b.builtinKey } : {}),
|
|
67
68
|
})),
|
|
@@ -81,9 +82,10 @@ export const createAgentInstructionToolConfig = {
|
|
|
81
82
|
projectId: z.string().uuid().optional().describe('Required for scope=project.'),
|
|
82
83
|
customerId: z.string().uuid().optional().describe('Required for scope=customer.'),
|
|
83
84
|
enabled: z.boolean().optional(),
|
|
85
|
+
mustRead: z.boolean().optional(),
|
|
84
86
|
sortOrder: z.number().int().optional(),
|
|
85
|
-
allowOversize: z.boolean().optional().describe('Override the hard size
|
|
86
|
-
oversizeReason: z.string().min(10).max(500).optional().describe('Required with allowOversize=true
|
|
87
|
+
allowOversize: z.boolean().optional().describe('Override the hard size cap (800 chars) after a blocked call.'),
|
|
88
|
+
oversizeReason: z.string().min(10).max(500).optional().describe('Required with allowOversize=true: why the body needs the length (audit-logged).'),
|
|
87
89
|
}).shape,
|
|
88
90
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
89
91
|
};
|
|
@@ -113,9 +115,10 @@ export const updateAgentInstructionToolConfig = {
|
|
|
113
115
|
title: z.string().min(1).max(120).optional(),
|
|
114
116
|
body: z.string().min(1).max(8000).optional(),
|
|
115
117
|
enabled: z.boolean().optional(),
|
|
118
|
+
mustRead: z.boolean().optional(),
|
|
116
119
|
sortOrder: z.number().int().optional(),
|
|
117
|
-
allowOversize: z.boolean().optional().describe('Override the hard size
|
|
118
|
-
oversizeReason: z.string().min(10).max(500).optional().describe('Required with allowOversize=true
|
|
120
|
+
allowOversize: z.boolean().optional().describe('Override the hard size cap (800 chars) after a blocked call.'),
|
|
121
|
+
oversizeReason: z.string().min(10).max(500).optional().describe('Required with allowOversize=true: why the body needs the length (audit-logged).'),
|
|
119
122
|
}).shape,
|
|
120
123
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
121
124
|
};
|
|
@@ -11,12 +11,14 @@ import { z } from 'zod';
|
|
|
11
11
|
import { makeAgentMessageWorkHandler } from './agent-message-work.js';
|
|
12
12
|
import { mcpInstanceToken } from './shared.js';
|
|
13
13
|
const PartySchema = z.object({ userId: z.string(), email: z.string(), sessionId: z.string().nullable(), role: z.string().nullable(), label: z.string() });
|
|
14
|
-
|
|
14
|
+
const ESCALATED = ['', 'escalated: mail and channels', 'escalated: deputy'];
|
|
15
|
+
/** ORB-2563 - `[urgent]`, `sent N times` and `reminder N` next to a message, the same words the CLI prints; ORB-2564 - the ladder step it reached. */
|
|
15
16
|
export function repeatMarks(m) {
|
|
16
17
|
const marks = [
|
|
17
18
|
m.priority === 'urgent' ? 'urgent' : '',
|
|
18
19
|
m.repeatCount && m.repeatCount > 1 ? `sent ${m.repeatCount} times` : '',
|
|
19
20
|
m.reminderCount ? `reminder ${m.reminderCount}` : '',
|
|
21
|
+
ESCALATED[m.escalationStep ?? 0] ?? '',
|
|
20
22
|
].filter(Boolean);
|
|
21
23
|
return marks.length ? ` [${marks.join(', ')}]` : '';
|
|
22
24
|
}
|
|
@@ -104,9 +106,11 @@ export function makeAgentMessagesHandler(client) {
|
|
|
104
106
|
feedbackSent = res.feedbackSent;
|
|
105
107
|
}
|
|
106
108
|
let acked = 0;
|
|
109
|
+
let escalationsStopped = 0;
|
|
107
110
|
if (args.ackIds && args.ackIds.length > 0) {
|
|
108
111
|
const res = await client.post('/v1/agent/messages/ack', { ids: args.ackIds, instanceToken: mcpInstanceToken(undefined, extra) });
|
|
109
112
|
acked = res.acked;
|
|
113
|
+
escalationsStopped = res.escalationsStopped ?? 0;
|
|
110
114
|
}
|
|
111
115
|
const compact = !args.all;
|
|
112
116
|
const q = new URLSearchParams();
|
|
@@ -137,6 +141,8 @@ export function makeAgentMessagesHandler(client) {
|
|
|
137
141
|
if (args.dismiss) {
|
|
138
142
|
lines.unshift(`Dismissed ${dismissedCount} message(s) as ${args.dismiss.reason}${closedCount > 0 ? `, ${closedCount} closed for the account` : ''}${feedbackSent > 0 ? `, ${feedbackSent} sender(s) told about the misroute` : ''}.`);
|
|
139
143
|
}
|
|
144
|
+
if (escalationsStopped > 0)
|
|
145
|
+
lines.unshift(`Stopped the urgent escalation of ${escalationsStopped} message(s).`);
|
|
140
146
|
if (sessionId)
|
|
141
147
|
lines.push(`This session: ${sessionId.slice(0, 8)} (ref ${instanceToken}).`);
|
|
142
148
|
else
|
|
@@ -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.
|
|
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.',
|
|
251
251
|
inputSchema: z.object({
|
|
252
252
|
factId: z.string().min(1).describe('UUID of the fact to verify.'),
|
|
253
253
|
}).shape,
|
|
@@ -1,13 +1,16 @@
|
|
|
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
5
|
export const ProjectReadinessItemSchema = z.object({
|
|
5
6
|
id: ProjectReadinessItemIdSchema,
|
|
6
7
|
status: ProjectReadinessStatusSchema,
|
|
7
8
|
reason: z.string(),
|
|
8
9
|
checked: z.number().int().nonnegative(),
|
|
9
10
|
missing: z.number().int().nonnegative(),
|
|
10
|
-
|
|
11
|
+
detail: z.string().optional().describe('The stored decision text behind the reason, e.g. why a project needs no headless lane.'),
|
|
12
|
+
fix: ProjectReadinessFixSchema,
|
|
13
|
+
alternatives: z.array(ProjectReadinessFixSchema).optional().describe('Other ways to resolve the item, offered next to fix.'),
|
|
11
14
|
});
|
|
12
15
|
export const ProjectReadinessSchema = z.object({
|
|
13
16
|
projectId: z.string().uuid(), projectKey: z.string(), ready: z.boolean(), language: z.string(),
|
package/dist/tools/query.js
CHANGED
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ORB-273 Phase F - `orboto_query`.
|
|
3
3
|
*
|
|
4
|
-
* @see ORB-531
|
|
4
|
+
* @see ORB-531, ORB-2581
|
|
5
5
|
*/
|
|
6
6
|
import { z } from 'zod';
|
|
7
|
+
import { OrbotoApiError } from '../orboto-client.js';
|
|
7
8
|
import { ticketLine, agentTicketListRow } from './shared.js';
|
|
8
9
|
export const queryToolConfig = {
|
|
9
10
|
title: 'Run an OQL / JQL query',
|
|
10
|
-
description: 'Run a typed OQL (or Jira-shaped JQL) query
|
|
11
|
+
description: 'Run a typed OQL (or Jira-shaped JQL) query and return matching tickets. '
|
|
11
12
|
+ 'OQL grammar: `[FIND tickets WHERE] field operator value [AND|OR ...] [ORDER BY ...] [LIMIT n]` (the frame is an optional alias). '
|
|
12
13
|
+ 'Supported fields: key, status, statusName, statusCategory, priority, type, assignee, reporter, reporterType, assigneeType, labels, milestone, version, project, isPrivate, dueDate, startDate, createdAt, updatedAt, closedAt, estimatedTimeMinutes, loggedMinutes, parentKey. '
|
|
13
|
-
+ 'reporterType / assigneeType are guest|internal
|
|
14
|
+
+ 'reporterType / assigneeType are guest|internal (`reporterType = guest OR assigneeType = guest` finds all guest tickets). '
|
|
14
15
|
+ 'Operators: = != < <= > >= ~ !~ IN IS [NOT] NULL IS [NOT] EMPTY. '
|
|
15
16
|
+ 'Functions: currentUser(), now(), startOfWeek(), endOfWeek(), startOfMonth(), endOfMonth(), daysAgo(n). '
|
|
16
17
|
+ 'Examples: '
|
|
17
18
|
+ '`project = ORB AND assignee = currentUser() AND statusCategory != done`; '
|
|
18
19
|
+ '`priority IN (blocker, high) AND dueDate <= now() ORDER BY dueDate ASC LIMIT 25`; '
|
|
19
20
|
+ '`labels = "bug" AND createdAt >= daysAgo(7)`. '
|
|
20
|
-
+ '
|
|
21
|
+
+ 'Use it when orboto_list_tickets / orboto_my_tickets cannot express the filter. '
|
|
22
|
+
+ 'A 429 is retried at most twice, then returned with structuredContent rateLimited, retryAfterSeconds, limitPerMinute.',
|
|
21
23
|
inputSchema: z.object({
|
|
22
24
|
oql: z.string().min(1).max(8000).describe('The query string.'),
|
|
23
25
|
syntax: z.enum(['oql', 'jql']).default('oql').describe('"jql" accepts Jira-flavoured syntax.'),
|
|
@@ -27,7 +29,35 @@ export const queryToolConfig = {
|
|
|
27
29
|
}).shape,
|
|
28
30
|
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
29
31
|
};
|
|
30
|
-
|
|
32
|
+
/** ORB-2581 - `orboto_query` waits out at most two 429s of up to 20 seconds each, then answers with the retry hint. */
|
|
33
|
+
export const QUERY_RETRY = { maxRetries: 2, maxWaitSeconds: 20 };
|
|
34
|
+
const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
35
|
+
function rateLimitDetails(err) {
|
|
36
|
+
let params = {};
|
|
37
|
+
try {
|
|
38
|
+
params = JSON.parse(err.body).errorParams ?? {};
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
params = {};
|
|
42
|
+
}
|
|
43
|
+
const fromBody = typeof params.retryAfterSeconds === 'number' ? params.retryAfterSeconds : null;
|
|
44
|
+
return {
|
|
45
|
+
retryAfterSeconds: Math.max(0, err.retryAfterSeconds ?? fromBody ?? 1),
|
|
46
|
+
limitPerMinute: typeof params.limitPerMinute === 'number' ? params.limitPerMinute : null,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function rateLimitedResult(details, attempts) {
|
|
50
|
+
const limit = details.limitPerMinute !== null ? ` (${details.limitPerMinute} per minute)` : '';
|
|
51
|
+
return {
|
|
52
|
+
isError: true,
|
|
53
|
+
content: [{
|
|
54
|
+
type: 'text',
|
|
55
|
+
text: `Rate limited: this account's OQL query limit${limit} is used up. Retry after ${details.retryAfterSeconds} seconds (tried ${attempts} times).`,
|
|
56
|
+
}],
|
|
57
|
+
structuredContent: { rateLimited: true, retryAfterSeconds: details.retryAfterSeconds, limitPerMinute: details.limitPerMinute, attempts },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function makeQueryHandler(client, sleep = realSleep) {
|
|
31
61
|
return async (input) => {
|
|
32
62
|
const body = {
|
|
33
63
|
oql: input.oql,
|
|
@@ -35,7 +65,20 @@ export function makeQueryHandler(client) {
|
|
|
35
65
|
cursor: input.cursor,
|
|
36
66
|
limit: input.limit ?? 25,
|
|
37
67
|
};
|
|
38
|
-
|
|
68
|
+
let page;
|
|
69
|
+
for (let attempt = 1; page === undefined; attempt += 1) {
|
|
70
|
+
try {
|
|
71
|
+
page = await client.post('/query', body);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
if (!(err instanceof OrbotoApiError) || err.status !== 429)
|
|
75
|
+
throw err;
|
|
76
|
+
const details = rateLimitDetails(err);
|
|
77
|
+
if (attempt > QUERY_RETRY.maxRetries || details.retryAfterSeconds > QUERY_RETRY.maxWaitSeconds)
|
|
78
|
+
return rateLimitedResult(details, attempt);
|
|
79
|
+
await sleep(details.retryAfterSeconds * 1000);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
39
82
|
const text = page.items.length === 0
|
|
40
83
|
? 'No tickets matched.'
|
|
41
84
|
: page.items.map((t) => `- ${ticketLine(t)}`).join('\n');
|
package/dist/tools/query.test.js
CHANGED
|
@@ -86,6 +86,75 @@ describe('tools/query', () => {
|
|
|
86
86
|
}));
|
|
87
87
|
});
|
|
88
88
|
});
|
|
89
|
+
import { metricsToolName } from '../tail-labels.js';
|
|
90
|
+
import { parseRetryAfter } from '../orboto-client.js';
|
|
91
|
+
import { QUERY_RETRY } from './query.js';
|
|
92
|
+
function rateLimited(retryAfter, errorParams = {}) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false, status: 429, statusText: 'Too Many Requests',
|
|
95
|
+
headers: new Headers(retryAfter === null ? {} : { 'retry-after': retryAfter }),
|
|
96
|
+
text: async () => JSON.stringify({ error: 'Rate limit exceeded', errorKey: 'errors.common.rate_limited', errorParams }),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function ok(json) {
|
|
100
|
+
return { ok: true, status: 200, statusText: 'OK', headers: new Headers(), json: async () => json };
|
|
101
|
+
}
|
|
102
|
+
describe('tools/query - 429 and Retry-After (ORB-2581)', () => {
|
|
103
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
|
|
104
|
+
it('waits the Retry-After seconds and retries until the query answers', async () => {
|
|
105
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch')
|
|
106
|
+
.mockResolvedValueOnce(rateLimited('3'))
|
|
107
|
+
.mockResolvedValueOnce(ok({ items: [], nextCursor: null }));
|
|
108
|
+
const sleep = vi.fn(async () => { });
|
|
109
|
+
const result = await makeQueryHandler(client, sleep)({ oql: 'project = ORB' });
|
|
110
|
+
expect(sleep).toHaveBeenCalledWith(3000);
|
|
111
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
112
|
+
expect(result.isError).toBeUndefined();
|
|
113
|
+
expect(result.content[0]).toEqual({ type: 'text', text: 'No tickets matched.' });
|
|
114
|
+
});
|
|
115
|
+
it('stops after the bounded retries and answers with the retry hint', async () => {
|
|
116
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(rateLimited('5', { retryAfterSeconds: 5, limitPerMinute: 60 }));
|
|
117
|
+
const sleep = vi.fn(async () => { });
|
|
118
|
+
const result = await makeQueryHandler(client, sleep)({ oql: 'project = ORB' });
|
|
119
|
+
expect(fetchSpy).toHaveBeenCalledTimes(QUERY_RETRY.maxRetries + 1);
|
|
120
|
+
expect(sleep).toHaveBeenCalledTimes(QUERY_RETRY.maxRetries);
|
|
121
|
+
expect(result.isError).toBe(true);
|
|
122
|
+
expect(result.content[0].text).toContain('Retry after 5 seconds');
|
|
123
|
+
expect(result.structuredContent).toEqual({ rateLimited: true, retryAfterSeconds: 5, limitPerMinute: 60, attempts: QUERY_RETRY.maxRetries + 1 });
|
|
124
|
+
});
|
|
125
|
+
it('never waits longer than the cap: a long Retry-After answers at once', async () => {
|
|
126
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(rateLimited(String(QUERY_RETRY.maxWaitSeconds + 1)));
|
|
127
|
+
const sleep = vi.fn(async () => { });
|
|
128
|
+
const result = await makeQueryHandler(client, sleep)({ oql: 'project = ORB' });
|
|
129
|
+
expect(sleep).not.toHaveBeenCalled();
|
|
130
|
+
expect(result.structuredContent).toMatchObject({ rateLimited: true, retryAfterSeconds: QUERY_RETRY.maxWaitSeconds + 1, attempts: 1 });
|
|
131
|
+
});
|
|
132
|
+
it('reads the wait from errorParams when a proxy dropped the header', async () => {
|
|
133
|
+
vi.spyOn(globalThis, 'fetch')
|
|
134
|
+
.mockResolvedValueOnce(rateLimited(null, { retryAfterSeconds: 7 }))
|
|
135
|
+
.mockResolvedValueOnce(ok({ items: [], nextCursor: null }));
|
|
136
|
+
const sleep = vi.fn(async () => { });
|
|
137
|
+
await makeQueryHandler(client, sleep)({ oql: 'project = ORB' });
|
|
138
|
+
expect(sleep).toHaveBeenCalledWith(7000);
|
|
139
|
+
});
|
|
140
|
+
it('passes every other error through unchanged', async () => {
|
|
141
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
142
|
+
ok: false, status: 400, statusText: 'Bad Request', headers: new Headers(), text: async () => '{"errorKey":"errors.oql.parse"}',
|
|
143
|
+
});
|
|
144
|
+
await expect(makeQueryHandler(client, vi.fn(async () => { }))({ oql: '((' })).rejects.toMatchObject({ status: 400 });
|
|
145
|
+
});
|
|
146
|
+
it('parses delta-seconds and HTTP dates', () => {
|
|
147
|
+
expect(parseRetryAfter('12')).toBe(12);
|
|
148
|
+
expect(parseRetryAfter(null)).toBeNull();
|
|
149
|
+
expect(parseRetryAfter('soon')).toBeNull();
|
|
150
|
+
expect(parseRetryAfter(new Date(Date.UTC(2026, 9, 18, 12, 0, 30)).toUTCString(), Date.UTC(2026, 9, 18, 12, 0, 0))).toBe(30);
|
|
151
|
+
});
|
|
152
|
+
it('labels the query limit tail routes for metrics', () => {
|
|
153
|
+
const id = '00000000-0000-4000-8000-000000000000';
|
|
154
|
+
expect(metricsToolName('orboto_api_call', { method: 'GET', path: `/admin/agents/identities/${id}/query-rate-limit` })).toBe('orboto_agent_query_rate_limit');
|
|
155
|
+
expect(metricsToolName('orboto_api_call', { method: 'PUT', path: `/admin/agents/identities/${id}/query-rate-limit` })).toBe('orboto_agent_query_rate_limit');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
89
158
|
import { agentTicketListRow } from './shared.js';
|
|
90
159
|
describe('agentTicketListRow (ORB-1699)', () => {
|
|
91
160
|
const FULL = {
|
|
@@ -18,11 +18,12 @@ import { channelStartLines } from '../inbox-channel.js';
|
|
|
18
18
|
import { rememberDeclaredScope } from '../session-scope-memory.js';
|
|
19
19
|
import { declareEnvProjectScope, envProjectScope } from '../project-scope.js';
|
|
20
20
|
import { agentHeadText } from '../agent-head.js';
|
|
21
|
+
import { agentContextKey } from '../agent-context.js';
|
|
21
22
|
export const sessionStartToolConfig = {
|
|
22
23
|
title: 'Load the rules you must follow + re-orient',
|
|
23
|
-
description: 'THE canonical way to LOAD the binding workspace rules you must follow as an agent. Run it
|
|
24
|
-
+ '**Parameters.** `rulesOnly: true` returns ONLY the complete rules text
|
|
25
|
-
+ '
|
|
24
|
+
description: 'THE canonical way to LOAD the binding workspace rules you must follow as an agent. Run it FIRST in a session and AFTER every context compaction. Returns the rules in full (above the full-text bound: one title per rule plus every must-read rule in full, the rest via `rulesOnly: true`); a compact "unchanged" ack only on a repeat inside the same agent context. Also returns your in-progress tickets (LANDED, IDLE = a linked commit but no move for days: finished work never handed to review), your timer and unhealthy git connections. Not orboto_list_agent_instructions - that one edits rule blocks for admins. Registers this session (a heartbeat on its instance row), otherwise read-only. '
|
|
25
|
+
+ '**Parameters.** `rulesOnly: true` returns ONLY the complete rules text, never truncated. `projectId` adds that project\'s rules. `ticketKey` ("ACME-42") bundles that ticket\'s primer, detail, dependencies, checklists, git health and other agent sessions. `forceRules: true` returns the full text inline even above the bound or after an ack - use it whenever the rules are NOT in your context. `agentKind` (coding, orchestrator, reviewer, runner) and `modelTier` (frontier, standard, small) target rule blocks and per-tier text. `scope` { projectKeys, ticketKeys, role } declares this session\'s responsibility; session.ref is what peers pass as toSessionRef (ORB-2136). '
|
|
26
|
+
+ 'Also carries the Knowledge block (ORB-2225: the wiki index of your declared projects and the Operations wiki; ask with orboto_knowledge_ask before a person, file durable facts with orboto_knowledge_add) and the Responsibilities block (ORB-2542: who owns the topics of your projects, orboto_who before agent mail). Right after the rules come Where knowledge lives (ORB-2575: rules bind behaviour, project facts hold fixed decisions, the wiki the long tail; a decision goes to a fact proposal, orboto_primer_fact_add with observed: true) and the Project facts of your declared projects.',
|
|
26
27
|
inputSchema: z.object({
|
|
27
28
|
projectId: z.string().uuid().optional().describe('Also load this project\'s rules.'),
|
|
28
29
|
ticketKey: z.string().min(3).optional().describe('Ticket key ("ACME-42") - bundles that ticket\'s full context.'),
|
|
@@ -133,6 +134,22 @@ async function buildTicketBundle(client, ticketKey) {
|
|
|
133
134
|
},
|
|
134
135
|
};
|
|
135
136
|
}
|
|
137
|
+
/** ORB-2575 - the facts block stays small; the rest is one expand away, like the Knowledge block. */
|
|
138
|
+
export const PROJECT_FACTS_BLOCK_CHARS = 3000;
|
|
139
|
+
export function projectFactsLines(text, owner = PROCESS_OWNER) {
|
|
140
|
+
if (text.length <= PROJECT_FACTS_BLOCK_CHARS)
|
|
141
|
+
return { lines: text.split('\n') };
|
|
142
|
+
const cut = text.slice(0, PROJECT_FACTS_BLOCK_CHARS);
|
|
143
|
+
const kept = cut.slice(0, Math.max(cut.lastIndexOf('\n'), 0));
|
|
144
|
+
const handle = storePayload('orboto_session_start', {
|
|
145
|
+
structuredContent: { projectFacts: text }, text,
|
|
146
|
+
omitted: [{ path: 'projectFacts', kind: 'string', omittedChars: text.length - kept.length }],
|
|
147
|
+
}, Date.now(), owner);
|
|
148
|
+
return {
|
|
149
|
+
handle,
|
|
150
|
+
lines: [...kept.split('\n'), `(Project facts cut at ${kept.length} of ${text.length} characters: orboto_response_expand { handle: "${handle}", path: "projectFacts" }, or orboto_get_project_primer.)`],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
136
153
|
/** ORB-2225 - the Knowledge block stays small; the rest is one expand away. */
|
|
137
154
|
export const KNOWLEDGE_BLOCK_CHARS = 1500;
|
|
138
155
|
export function knowledgeLines(text, owner = PROCESS_OWNER) {
|
|
@@ -164,7 +181,8 @@ export function identityLine(registration) {
|
|
|
164
181
|
return `This scope stays with this session across reconnects and restarts; declare it again only to change it.${kept}`;
|
|
165
182
|
}
|
|
166
183
|
export function makeSessionStartHandler(client, opts = {}) {
|
|
167
|
-
|
|
184
|
+
const currentContext = opts.agentContext ?? (() => agentContextKey());
|
|
185
|
+
let delivered;
|
|
168
186
|
return async (input = {}, extra) => {
|
|
169
187
|
const instanceToken = mcpInstanceToken(undefined, extra);
|
|
170
188
|
const rulesParams = new URLSearchParams();
|
|
@@ -172,14 +190,15 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
172
190
|
rulesParams.set('projectId', input.projectId);
|
|
173
191
|
applyAgentProfile(rulesParams, { agentKind: input.agentKind, modelTier: input.modelTier });
|
|
174
192
|
const wantsFullRules = input.forceRules === true || input.rulesOnly === true;
|
|
175
|
-
|
|
176
|
-
|
|
193
|
+
const context = currentContext();
|
|
194
|
+
if (context && delivered?.context === context && !wantsFullRules)
|
|
195
|
+
rulesParams.set('knownRulesHash', delivered.hash);
|
|
177
196
|
const rulesQs = rulesParams.toString();
|
|
178
197
|
const rulesPath = rulesQs ? `/agent-instructions?${rulesQs}` : '/agent-instructions';
|
|
179
198
|
if (input.rulesOnly) {
|
|
180
199
|
const rules = await loadRequiredRules(client, rulesPath);
|
|
181
|
-
if (rules.rulesHash)
|
|
182
|
-
|
|
200
|
+
if (context && rules.rulesHash)
|
|
201
|
+
delivered = { context, hash: rules.rulesHash };
|
|
183
202
|
const text = rules.instructions ?? '';
|
|
184
203
|
return {
|
|
185
204
|
_meta: { [PROTECT_TEXT_META]: true },
|
|
@@ -211,7 +230,7 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
211
230
|
if (input.scope === undefined && registration && !registration.scope && envProjectScope()) {
|
|
212
231
|
registration = toRegistration(await declareEnvProjectScope(client, instanceToken)) ?? registration;
|
|
213
232
|
}
|
|
214
|
-
const [me, rules, assigned, timer, inboxRaw, knowledge, owners] = await Promise.all([
|
|
233
|
+
const [me, rules, assigned, timer, inboxRaw, knowledge, owners, projectFacts] = await Promise.all([
|
|
215
234
|
client.get('/users/me').catch(() => null),
|
|
216
235
|
loadRequiredRules(client, rulesPath, rulesParams.get('knownRulesHash') ?? undefined),
|
|
217
236
|
client.get('/users/me/assigned-tickets?statuses=IN_PROGRESS,IN_REVIEW&limit=20').catch(() => ({ items: [] })),
|
|
@@ -225,6 +244,9 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
225
244
|
client.get('/responsibilities/block', { instanceToken })
|
|
226
245
|
.then((r) => (typeof r?.text === 'string' && r.text ? { text: r.text, scoped: Array.isArray(r.entries) ? r.entries.length : 0, total: r.total ?? 0 } : null))
|
|
227
246
|
.catch(() => null),
|
|
247
|
+
client.get('/project-facts', { instanceToken })
|
|
248
|
+
.then((r) => (typeof r?.text === 'string' && r.text.trim() && Array.isArray(r.projects) && Array.isArray(r.unknown) ? r : null))
|
|
249
|
+
.catch(() => null),
|
|
228
250
|
]);
|
|
229
251
|
const pendingMessages = inboxRaw.map((m) => ({ id: m.id, fromUserId: m.fromUserId, from: m.from?.label ?? m.fromUserId, kind: m.kind, subject: m.subject, createdAt: m.createdAt }));
|
|
230
252
|
const tickets = Array.isArray(assigned) ? assigned : (assigned?.items ?? []);
|
|
@@ -256,18 +278,18 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
256
278
|
if (me?.workspaceLocale || me?.locale)
|
|
257
279
|
lines.push(`Write tickets / comments / docs in: ${me.workspaceLocale ?? me.locale}.`);
|
|
258
280
|
const rulesText = rules.instructions ?? '';
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const deliverIndex = !rules.rulesUnchanged && input.forceRules !== true && rulesIndex.length > 0 && rulesText.length > 0;
|
|
281
|
+
const indexEntries = Array.isArray(rules.rulesIndex) ? rules.rulesIndex.filter((e) => typeof e?.title === 'string' && e.title.length > 0) : [];
|
|
282
|
+
const rulesIndex = indexEntries.map((e) => e.title);
|
|
283
|
+
const mustRead = indexEntries.filter((e) => e.mustRead === true && typeof e.body === 'string' && e.body.trim().length > 0);
|
|
284
|
+
const deliverIndex = !rules.rulesUnchanged && input.forceRules !== true && rules.rulesDelivery === 'index' && rulesIndex.length > 0 && rulesText.length > 0;
|
|
263
285
|
const deliverFull = !rules.rulesUnchanged && !deliverIndex && rulesText.length > 0;
|
|
264
286
|
const rulesChars = rules.rulesChars ?? rulesText.length;
|
|
265
287
|
const HOW_TO_READ_RULES = 'Call orboto_session_start { rulesOnly: true } for the complete rule text (one call, never truncated). '
|
|
266
|
-
+ 'Do that whenever you do not already hold this exact rulesHash
|
|
288
|
+
+ 'Do that whenever you do not already hold this exact rulesHash.';
|
|
267
289
|
let rulesHandle;
|
|
268
290
|
lines.push('', '## Working rules - follow these');
|
|
269
291
|
if (rules.rulesUnchanged) {
|
|
270
|
-
lines.push(`Unchanged since this
|
|
292
|
+
lines.push(`Unchanged since this agent context received them in full (hash ${rules.rulesHash}), so they were left out to save context.`, 'If the rules are NOT in your context right now, call this tool again with rulesOnly=true (rules alone) or forceRules=true (rules plus this digest) and read them in full. Do not proceed on a half-remembered rule set.');
|
|
271
293
|
}
|
|
272
294
|
else if (deliverIndex) {
|
|
273
295
|
rulesHandle = storePayload('orboto_session_start', {
|
|
@@ -275,11 +297,16 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
275
297
|
text: rules.instructions ?? '',
|
|
276
298
|
omitted: [{ path: 'rules', kind: 'string', omittedChars: rulesText.length }],
|
|
277
299
|
}, Date.now(), client);
|
|
278
|
-
lines.push(`${rulesIndex.length} rule(s) bind you (${rulesChars} characters, hash ${rules.rulesHash}).
|
|
300
|
+
lines.push(`${rulesIndex.length} rule(s) bind you (${rulesChars} characters, hash ${rules.rulesHash}). The set is above the full-text bound, so the titles are printed and the ${mustRead.length} must-read rule(s) in full.`, `${HOW_TO_READ_RULES} Read any rule below whose title touches what you are about to do BEFORE you do it. (orboto_response_expand { handle: "${rulesHandle}", path: "rules" } serves the same text from this process for 15 minutes.)`, ...indexEntries.map((e, i) => `${i + 1}. ${e.title}${mustRead.includes(e) ? ' (must read, in full below)' : ''}`));
|
|
301
|
+
for (const e of mustRead)
|
|
302
|
+
lines.push('', `### Must read: ${e.title}`, String(e.body).trim());
|
|
279
303
|
}
|
|
280
304
|
else {
|
|
281
305
|
lines.push(rulesText || '(no workspace rules configured)');
|
|
282
306
|
}
|
|
307
|
+
const factsBlock = projectFacts ? projectFactsLines(projectFacts.text.trim(), client) : null;
|
|
308
|
+
if (factsBlock)
|
|
309
|
+
lines.push('', ...factsBlock.lines);
|
|
283
310
|
lines.push('', '## Your in-progress work');
|
|
284
311
|
if (tickets.length === 0)
|
|
285
312
|
lines.push('No tickets currently assigned to you - claim or create one before you start coding.');
|
|
@@ -336,7 +363,7 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
336
363
|
rulesUnchanged: rules.rulesUnchanged === true,
|
|
337
364
|
rulesDelivery: rules.rulesUnchanged ? 'ack' : deliverIndex ? 'index' : 'full',
|
|
338
365
|
...(deliverIndex
|
|
339
|
-
? { rulesIndex, rulesChars, rulesHandle, rulesHowToRead: HOW_TO_READ_RULES }
|
|
366
|
+
? { rulesIndex, rulesChars, rulesHandle, rulesHowToRead: HOW_TO_READ_RULES, mustRead: mustRead.map((e) => e.title) }
|
|
340
367
|
: {}),
|
|
341
368
|
...(rules.rulesUnchanged ? { rulesHowToRead: HOW_TO_READ_RULES } : {}),
|
|
342
369
|
inProgress: scopedTickets.map((t) => ({
|
|
@@ -358,12 +385,22 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
358
385
|
healthyCount: gitHealthWithConnections.reduce((n, p) => n + p.connections.filter((c) => c.healthy).length, 0),
|
|
359
386
|
},
|
|
360
387
|
...(knowledge ? { knowledge: knowledgeSummary(knowledge, knowledgeBlock?.handle) } : {}),
|
|
388
|
+
...(projectFacts ? {
|
|
389
|
+
projectFacts: {
|
|
390
|
+
source: projectFacts.source,
|
|
391
|
+
projects: projectFacts.projects.map((p) => ({ projectKey: p.projectKey, total: p.total, shown: Array.isArray(p.facts) ? p.facts.length : 0 })),
|
|
392
|
+
workspace: { total: projectFacts.workspace?.total ?? 0, shown: Array.isArray(projectFacts.workspace?.facts) ? projectFacts.workspace.facts.length : 0 },
|
|
393
|
+
unknown: projectFacts.unknown,
|
|
394
|
+
...(factsBlock?.handle ? { handle: factsBlock.handle } : {}),
|
|
395
|
+
},
|
|
396
|
+
} : {}),
|
|
361
397
|
...(owners ? { responsibilities: { scoped: owners.scoped, total: owners.total } } : {}),
|
|
362
398
|
...(pendingMessages.length > 0 ? { pendingMessages } : {}),
|
|
363
399
|
...(bundle ? { ticketBundle: bundle.structured } : {}),
|
|
364
400
|
},
|
|
365
401
|
};
|
|
366
|
-
|
|
402
|
+
if (context && deliverFull && rules.rulesHash)
|
|
403
|
+
delivered = { context, hash: rules.rulesHash };
|
|
367
404
|
return result;
|
|
368
405
|
};
|
|
369
406
|
}
|
|
@@ -11,10 +11,12 @@ import { OrbotoClient } from '../orboto-client.js';
|
|
|
11
11
|
import { makeSessionStartHandler } from './session-start.js';
|
|
12
12
|
import { _forgetDeclaredScopes, rememberedScope } from '../session-scope-memory.js';
|
|
13
13
|
import { CHANNEL_START_COMMANDS, CHANNEL_START_COMMANDS_WITHOUT_CLI } from '../inbox-channel.js';
|
|
14
|
-
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { ORBOTO_HOOK_EVENTS, agentContextKey } from '../agent-context.js';
|
|
15
17
|
import { makeResponseExpandHandler } from './response-expand.js';
|
|
16
18
|
import { applyResponseBudget, budgetFor, DEFAULT_BUDGET_CHARS } from '../response-budget.js';
|
|
17
|
-
import { hostname } from 'node:os';
|
|
19
|
+
import { hostname, tmpdir } from 'node:os';
|
|
18
20
|
import { localSessionHost } from './shared.js';
|
|
19
21
|
beforeEach(() => { vi.restoreAllMocks(); });
|
|
20
22
|
afterEach(() => { vi.restoreAllMocks(); });
|
|
@@ -120,8 +122,8 @@ describe('orboto_session_start (ORB-1093)', () => {
|
|
|
120
122
|
expect(text).not.toContain('Git connection health - WARNING');
|
|
121
123
|
});
|
|
122
124
|
});
|
|
123
|
-
describe('orboto_session_start - rules-hash ack (ORB-1607)', () => {
|
|
124
|
-
it('sends no knownRulesHash on the first call, then passes the received hash back
|
|
125
|
+
describe('orboto_session_start - rules-hash ack (ORB-1607, ORB-2576)', () => {
|
|
126
|
+
it('sends no knownRulesHash on the first call, then passes the received hash back inside the same agent context', async () => {
|
|
125
127
|
const calls = [];
|
|
126
128
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
|
|
127
129
|
const u = new URL(url.toString());
|
|
@@ -141,7 +143,8 @@ describe('orboto_session_start - rules-hash ack (ORB-1607)', () => {
|
|
|
141
143
|
const key = Object.keys(fallback).find((k) => (u.pathname + u.search).startsWith(k));
|
|
142
144
|
return { ok: true, status: 200, statusText: 'OK', json: async () => (key ? fallback[key] : {}), text: async () => '' };
|
|
143
145
|
});
|
|
144
|
-
|
|
146
|
+
let context = 'claude-code sess-a 2026-10-18T10:00:00Z';
|
|
147
|
+
const handler = makeSessionStartHandler(client, { agentContext: () => context });
|
|
145
148
|
const first = await handler();
|
|
146
149
|
const firstCall = calls.find((c) => c.startsWith('/agent-instructions'));
|
|
147
150
|
expect(firstCall).toBe('/agent-instructions');
|
|
@@ -155,13 +158,23 @@ describe('orboto_session_start - rules-hash ack (ORB-1607)', () => {
|
|
|
155
158
|
const secondCall = calls.find((c) => c.startsWith('/agent-instructions'));
|
|
156
159
|
expect(secondCall).toBe('/agent-instructions?knownRulesHash=abc123def456');
|
|
157
160
|
const secondText = second.content[0].text;
|
|
158
|
-
expect(secondText).toContain('Unchanged since this
|
|
161
|
+
expect(secondText).toContain('Unchanged since this agent context received them in full');
|
|
159
162
|
expect(secondText).toContain('forceRules=true');
|
|
160
163
|
expect(secondText).not.toContain('keep following what you already loaded');
|
|
161
164
|
expect(secondText).not.toContain('claim -> commit -> close');
|
|
162
165
|
const secondStructured = second.structuredContent;
|
|
163
166
|
expect(secondStructured.rulesHash).toBe('abc123def456');
|
|
164
167
|
expect(secondStructured.rulesUnchanged).toBe(true);
|
|
168
|
+
calls.length = 0;
|
|
169
|
+
context = 'claude-code sess-a 2026-10-18T11:00:00Z';
|
|
170
|
+
const afterCompaction = await handler();
|
|
171
|
+
expect(calls.find((c) => c.startsWith('/agent-instructions'))).toBe('/agent-instructions');
|
|
172
|
+
expect(afterCompaction.content[0].text).toContain('claim -> commit -> close');
|
|
173
|
+
calls.length = 0;
|
|
174
|
+
context = '';
|
|
175
|
+
await handler();
|
|
176
|
+
await handler();
|
|
177
|
+
expect(calls.filter((c) => c.startsWith('/agent-instructions'))).toEqual(['/agent-instructions', '/agent-instructions']);
|
|
165
178
|
});
|
|
166
179
|
it('a fresh handler (new connection) starts with no cached hash again', async () => {
|
|
167
180
|
const calls = stubByPath({
|
|
@@ -175,6 +188,46 @@ describe('orboto_session_start - rules-hash ack (ORB-1607)', () => {
|
|
|
175
188
|
expect(call).toBe('/agent-instructions');
|
|
176
189
|
});
|
|
177
190
|
});
|
|
191
|
+
describe('orboto_session_start - knowledge layers and project facts (ORB-2575)', () => {
|
|
192
|
+
const FACTS_TEXT = '## Where knowledge lives\n- Rules: binding behaviour.\n\n## Project facts\n### ORB (project id proj-1): 1 of 3 facts\n- tech_stack/stack: Go and Postgres\n2 more: `orboto primer ORB`';
|
|
193
|
+
const facts = (text) => ({
|
|
194
|
+
source: 'session', unknown: [], text,
|
|
195
|
+
projects: [{ projectKey: 'ORB', projectId: 'proj-1', total: 3, facts: [{ category: 'tech_stack', key: 'stack', value: 'Go and Postgres', observed: false }] }],
|
|
196
|
+
workspace: { total: 0, facts: [] },
|
|
197
|
+
});
|
|
198
|
+
const base = {
|
|
199
|
+
'/users/me/assigned-tickets': { items: [] },
|
|
200
|
+
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
201
|
+
'/agent-instructions': { instructions: 'RULE TEXT', rulesHash: 'h1' },
|
|
202
|
+
'/time/timer': {},
|
|
203
|
+
};
|
|
204
|
+
it('prints the layers and the facts right after the rules, and summarises the counts', async () => {
|
|
205
|
+
const calls = stubByPath({ ...base, '/project-facts': facts(FACTS_TEXT) });
|
|
206
|
+
const res = await makeSessionStartHandler(client)();
|
|
207
|
+
const text = res.content[0].text;
|
|
208
|
+
expect(calls).toContain('/project-facts');
|
|
209
|
+
expect(text).toContain(`RULE TEXT\n\n${FACTS_TEXT}\n\n## Your in-progress work`);
|
|
210
|
+
expect(res.structuredContent.projectFacts).toEqual({
|
|
211
|
+
source: 'session', projects: [{ projectKey: 'ORB', total: 3, shown: 1 }], workspace: { total: 0, shown: 0 }, unknown: [],
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
it('a long facts block is cut with a handle to the rest', async () => {
|
|
215
|
+
const long = `${FACTS_TEXT}\n${Array.from({ length: 80 }, (_, i) => `- other/fact-${i}: ${'v'.repeat(60)}`).join('\n')}`;
|
|
216
|
+
stubByPath({ ...base, '/project-facts': facts(long) });
|
|
217
|
+
const res = await makeSessionStartHandler(client)();
|
|
218
|
+
const text = res.content[0].text;
|
|
219
|
+
const handle = res.structuredContent.projectFacts.handle;
|
|
220
|
+
expect(handle).toBeTruthy();
|
|
221
|
+
expect(text).toContain(`orboto_response_expand { handle: "${handle}", path: "projectFacts" }`);
|
|
222
|
+
expect(text).not.toContain('fact-79');
|
|
223
|
+
});
|
|
224
|
+
it('an API without the block, or with a malformed one, leaves the section out', async () => {
|
|
225
|
+
stubByPath({ ...base, '/project-facts': { text: '## Project facts' } });
|
|
226
|
+
const res = await makeSessionStartHandler(client)();
|
|
227
|
+
expect(res.content[0].text).not.toContain('## Project facts');
|
|
228
|
+
expect(res.structuredContent.projectFacts).toBeUndefined();
|
|
229
|
+
});
|
|
230
|
+
});
|
|
178
231
|
describe('orboto_session_start - ticketKey bundle (ORB-1607)', () => {
|
|
179
232
|
const bundleStubs = {
|
|
180
233
|
'/projects/by-key/ORB': { id: 'proj-1', key: 'ORB', name: 'orboto' },
|
|
@@ -263,9 +316,28 @@ describe('orboto_session_start - context cost (ORB-1697)', () => {
|
|
|
263
316
|
expect(text).toContain('THE FULL RULE TEXT');
|
|
264
317
|
expect(text).not.toContain('forceRules=true');
|
|
265
318
|
});
|
|
266
|
-
it('
|
|
319
|
+
it('ORB-2576 - a subagent inside a running session gets the rules in full', async () => {
|
|
320
|
+
const dir = mkdtempSync(join(tmpdir(), 'orboto-mcp-subagent-'));
|
|
321
|
+
mkdirSync(join(dir, '.claude'), { recursive: true });
|
|
322
|
+
writeFileSync(join(dir, '.claude', 'settings.local.json'), JSON.stringify({ hooks: Object.fromEntries(ORBOTO_HOOK_EVENTS.map((e) => [e, [{ hooks: [{ type: 'command', command: 'orboto claude-hook' }] }]])) }));
|
|
323
|
+
const turn = (extra) => {
|
|
324
|
+
mkdirSync(join(dir, '.orboto', 'claude-turn'), { recursive: true });
|
|
325
|
+
writeFileSync(join(dir, '.orboto', 'claude-turn', 'sess-a.json'), JSON.stringify({ sessionId: 'sess-a', cwd: dir, state: 'idle', contextStartedAt: '2026-10-18T10:00:00Z', ...extra }));
|
|
326
|
+
};
|
|
327
|
+
const env = { CLAUDE_CODE_SESSION_ID: 'sess-a' };
|
|
267
328
|
const calls = stubByPath(rulesStub);
|
|
268
|
-
const handler = makeSessionStartHandler(client);
|
|
329
|
+
const handler = makeSessionStartHandler(client, { agentContext: () => agentContextKey(env, dir) });
|
|
330
|
+
turn({});
|
|
331
|
+
await handler();
|
|
332
|
+
turn({ activeSubagents: ['agent-1'], contextStartedAt: '2026-10-18T10:05:00Z' });
|
|
333
|
+
calls.length = 0;
|
|
334
|
+
const inSubagent = await handler();
|
|
335
|
+
expect(calls.find((c) => c.startsWith('/agent-instructions'))).toBe('/agent-instructions');
|
|
336
|
+
expect(inSubagent.content[0].text).toContain('THE FULL RULE TEXT');
|
|
337
|
+
});
|
|
338
|
+
it('without forceRules the ack path is still used inside one agent context - the saving is not lost', async () => {
|
|
339
|
+
const calls = stubByPath(rulesStub);
|
|
340
|
+
const handler = makeSessionStartHandler(client, { agentContext: () => 'claude-code sess-a 2026-10-18T10:00:00Z' });
|
|
269
341
|
await handler();
|
|
270
342
|
calls.length = 0;
|
|
271
343
|
await handler();
|
|
@@ -412,7 +484,7 @@ describe('ORB-1818 - rules index instead of the full rule text', () => {
|
|
|
412
484
|
const BLOCK_TITLES = Array.from({ length: 27 }, (_, i) => `Rule block number ${i + 1} - a realistic operator-authored title`);
|
|
413
485
|
const RULES_TEXT = BLOCK_TITLES.map((t, i) => `${t}\n${'x'.repeat(650)}${i}`).join('\n\n');
|
|
414
486
|
const RULES_INDEX = BLOCK_TITLES.map((title) => ({ title, chars: 651 }));
|
|
415
|
-
const rulesFull = { instructions: RULES_TEXT, rulesHash: 'hash1818aaaa', rulesIndex: RULES_INDEX, rulesChars: RULES_TEXT.length };
|
|
487
|
+
const rulesFull = { instructions: RULES_TEXT, rulesHash: 'hash1818aaaa', rulesIndex: RULES_INDEX, rulesChars: RULES_TEXT.length, rulesDelivery: 'index' };
|
|
416
488
|
function stubDigest(rules) {
|
|
417
489
|
return stubByPath({
|
|
418
490
|
'/users/me/assigned-tickets': {
|
|
@@ -469,9 +541,9 @@ describe('ORB-1818 - rules index instead of the full rule text', () => {
|
|
|
469
541
|
expect(chunks.join('')).toBe(RULES_TEXT);
|
|
470
542
|
});
|
|
471
543
|
it('an acked hash returns neither the text NOR the index', async () => {
|
|
472
|
-
const handler = makeSessionStartHandler(client);
|
|
544
|
+
const handler = makeSessionStartHandler(client, { agentContext: () => 'claude-code sess-a 2026-10-18T10:00:00Z' });
|
|
473
545
|
stubDigest(rulesFull);
|
|
474
|
-
await handler();
|
|
546
|
+
await handler({ forceRules: true });
|
|
475
547
|
vi.restoreAllMocks();
|
|
476
548
|
stubDigest({ rulesHash: 'hash1818aaaa', rulesUnchanged: true });
|
|
477
549
|
const second = await handler();
|
|
@@ -480,7 +552,7 @@ describe('ORB-1818 - rules index instead of the full rule text', () => {
|
|
|
480
552
|
expect(structured.rulesIndex).toBeUndefined();
|
|
481
553
|
expect(structured.rules).toBe('');
|
|
482
554
|
const text = second.content[0].text;
|
|
483
|
-
expect(text).toContain('Unchanged since this
|
|
555
|
+
expect(text).toContain('Unchanged since this agent context received them in full');
|
|
484
556
|
expect(text).toContain('rulesOnly=true');
|
|
485
557
|
expect(text).not.toContain('Rule block number 1');
|
|
486
558
|
});
|
|
@@ -521,6 +593,31 @@ describe('ORB-1818 - rules index instead of the full rule text', () => {
|
|
|
521
593
|
await handler({ rulesOnly: true });
|
|
522
594
|
expect(calls[0]).toBe('/agent-instructions');
|
|
523
595
|
});
|
|
596
|
+
it('ORB-2576 - a set under the bound comes in full even with an index', async () => {
|
|
597
|
+
stubDigest({ ...rulesFull, rulesDelivery: 'full' });
|
|
598
|
+
const res = await makeSessionStartHandler(client)();
|
|
599
|
+
expect(res.structuredContent.rulesDelivery).toBe('full');
|
|
600
|
+
expect(res.content[0].text).toContain(RULES_TEXT);
|
|
601
|
+
});
|
|
602
|
+
it('ORB-2576 - above the bound every must-read rule comes in full after the titles', async () => {
|
|
603
|
+
const index = RULES_INDEX.map((e, i) => (i === 1 ? { ...e, mustRead: true, body: 'MUST READ BODY' } : e));
|
|
604
|
+
stubDigest({ ...rulesFull, rulesIndex: index });
|
|
605
|
+
const res = await makeSessionStartHandler(client)();
|
|
606
|
+
const text = res.content[0].text;
|
|
607
|
+
expect(text).toContain('so the titles are printed and the 1 must-read rule(s) in full.');
|
|
608
|
+
expect(text).toContain(`2. ${BLOCK_TITLES[1]} (must read, in full below)`);
|
|
609
|
+
expect(text).toContain(`### Must read: ${BLOCK_TITLES[1]}\nMUST READ BODY`);
|
|
610
|
+
expect(res.structuredContent.mustRead).toEqual([BLOCK_TITLES[1]]);
|
|
611
|
+
});
|
|
612
|
+
it('ORB-2576 - an index delivery is no receipt: the next start in the same context sends no known hash', async () => {
|
|
613
|
+
const handler = makeSessionStartHandler(client, { agentContext: () => 'claude-code sess-a 2026-10-18T10:00:00Z' });
|
|
614
|
+
stubDigest(rulesFull);
|
|
615
|
+
await handler();
|
|
616
|
+
vi.restoreAllMocks();
|
|
617
|
+
const calls = stubDigest(rulesFull);
|
|
618
|
+
await handler();
|
|
619
|
+
expect(calls.find((c) => c.startsWith('/agent-instructions'))).toBe('/agent-instructions');
|
|
620
|
+
});
|
|
524
621
|
it('an API too old to send an index still delivers the full rules inline', async () => {
|
|
525
622
|
stubDigest({ instructions: RULES_TEXT, rulesHash: 'oldapi' });
|
|
526
623
|
const res = await makeSessionStartHandler(client)();
|
|
@@ -13,7 +13,7 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
|
13
13
|
const LOCALE_CODES = ['en', 'de', 'fr', 'es', 'it', 'nl', 'pt', 'ru', 'pl', 'tr', 'cs', 'da', 'sv', 'no', 'fi', 'ja', 'zh', 'ko'];
|
|
14
14
|
export const updateProjectToolConfig = {
|
|
15
15
|
title: 'Update a project\'s metadata',
|
|
16
|
-
description: 'Patch a project
|
|
16
|
+
description: 'Patch a project; set at least one field. `status` archived or closed leaves rotation. A `key` rename rewrites `PROJ-N` references. `language` null inherits. `noLaneReason`: no headless lane needed (bots work interactively); null clears.',
|
|
17
17
|
inputSchema: z.object({
|
|
18
18
|
projectKey: z.string().min(1).describe('Current project key.'),
|
|
19
19
|
patch: z.object({
|
|
@@ -28,6 +28,7 @@ export const updateProjectToolConfig = {
|
|
|
28
28
|
.describe('Customer UUID; null detaches.'),
|
|
29
29
|
language: z.enum(LOCALE_CODES).nullable().optional()
|
|
30
30
|
.describe('Content language; null inherits workspace.'),
|
|
31
|
+
noLaneReason: z.string().nullable().optional(),
|
|
31
32
|
}).refine((p) => Object.keys(p).length > 0, { message: 'patch must include at least one field' }),
|
|
32
33
|
}).shape,
|
|
33
34
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
@@ -49,6 +50,7 @@ export function makeUpdateProjectHandler(client) {
|
|
|
49
50
|
description: updated.description,
|
|
50
51
|
status: updated.status,
|
|
51
52
|
language: updated.language ?? null,
|
|
53
|
+
noLaneReason: updated.noLaneReason ?? null,
|
|
52
54
|
spec: updated.spec,
|
|
53
55
|
},
|
|
54
56
|
};
|
|
@@ -30,6 +30,16 @@ function stub(responses) {
|
|
|
30
30
|
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
31
31
|
const PROJ = { id: 'p1', key: 'ACME', name: 'Acme', description: null, status: 'active' };
|
|
32
32
|
describe('orboto_update_project', () => {
|
|
33
|
+
it('ORB-2569 records and clears the no-lane decision, string or null', async () => {
|
|
34
|
+
const calls = stub([{ json: PROJ }, { json: { ...PROJ, noLaneReason: 'Bots work interactively' } }, { json: PROJ }, { json: { ...PROJ, noLaneReason: null } }]);
|
|
35
|
+
const handler = makeUpdateProjectHandler(client);
|
|
36
|
+
const set = await handler({ projectKey: 'ACME', patch: { noLaneReason: 'Bots work interactively' } });
|
|
37
|
+
expect(calls[1]).toMatchObject({ method: 'PATCH', body: { noLaneReason: 'Bots work interactively' } });
|
|
38
|
+
expect(set.structuredContent).toMatchObject({ noLaneReason: 'Bots work interactively' });
|
|
39
|
+
const cleared = await handler({ projectKey: 'ACME', patch: { noLaneReason: null } });
|
|
40
|
+
expect(calls[3]).toMatchObject({ body: { noLaneReason: null } });
|
|
41
|
+
expect(cleared.structuredContent).toMatchObject({ noLaneReason: null });
|
|
42
|
+
});
|
|
33
43
|
it('PATCHes only the supplied fields', async () => {
|
|
34
44
|
const calls = stub([
|
|
35
45
|
{ json: PROJ },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orboto/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.223.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",
|