@orboto/mcp 0.206.2 → 0.208.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/index.js +2 -0
- package/dist/project-scope.js +28 -0
- package/dist/project-scope.test.js +86 -0
- package/dist/server.js +5 -1
- package/dist/tools/agent-drift.js +1 -1
- package/dist/tools/agent-instructions.js +2 -2
- package/dist/tools/agent-messages.js +1 -1
- package/dist/tools/agent-wakes.js +1 -1
- package/dist/tools/backup.js +1 -1
- package/dist/tools/bulk-create.js +2 -2
- package/dist/tools/check-similar.js +1 -1
- package/dist/tools/customer-draft.js +1 -1
- package/dist/tools/get-project-primer.js +1 -1
- package/dist/tools/help.js +1 -1
- package/dist/tools/knowledge.js +35 -0
- package/dist/tools/knowledge.test.js +74 -0
- package/dist/tools/sentry.js +1 -1
- package/dist/tools/session-start.js +41 -4
- package/dist/tools/wiki.js +28 -3
- package/dist/tools/work-sessions.js +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -62,6 +62,8 @@ async function main() {
|
|
|
62
62
|
const { InboxChannel, digestMinutesFromEnv, cliOutdatedNotice, CLI_OUTDATED_NOTICE } = await import('./inbox-channel.js');
|
|
63
63
|
const { mcpProcessInstance } = await import('./tools/shared.js');
|
|
64
64
|
const instanceToken = mcpProcessInstance();
|
|
65
|
+
const { declareEnvProjectScope } = await import('./project-scope.js');
|
|
66
|
+
await declareEnvProjectScope(preflightClient, instanceToken);
|
|
65
67
|
const inbox = new InboxChannel({ ...clientConfig, instanceToken, mcp: server, digestMinutes: digestMinutesFromEnv(process.env.ORBOTO_MCP_CHANNEL_DIGEST_MINUTES) });
|
|
66
68
|
inbox.start();
|
|
67
69
|
const outdated = cliOutdatedNotice(process.env.ORBOTO_MCP_CLI_OUTDATED);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { rememberDeclaredScope } from './session-scope-memory.js';
|
|
2
|
+
/** Must stay equal to ProjectKeyEnv in cli/internal/cmd/project_scope.go (drift test). */
|
|
3
|
+
export const PROJECT_KEY_ENV = 'ORBOTO_PROJECT_KEY';
|
|
4
|
+
const PROJECT_KEY = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
|
|
5
|
+
/** The worker scope on the project the environment names, or null. */
|
|
6
|
+
export function envProjectScope(raw = process.env.ORBOTO_PROJECT_KEY) {
|
|
7
|
+
const key = raw?.trim();
|
|
8
|
+
if (!key || !PROJECT_KEY.test(key))
|
|
9
|
+
return null;
|
|
10
|
+
return { role: 'worker', projectKeys: [key.toUpperCase()] };
|
|
11
|
+
}
|
|
12
|
+
/** True when the scope names anything at all. */
|
|
13
|
+
export function scopeDeclared(scope) {
|
|
14
|
+
return !!scope && (!!scope.role || (scope.projectKeys?.length ?? 0) > 0 || (scope.ticketKeys?.length ?? 0) > 0);
|
|
15
|
+
}
|
|
16
|
+
/** Declares the environment's project on the session row unless the row already carries a scope; answers the row. */
|
|
17
|
+
export async function declareEnvProjectScope(client, instanceToken, raw = process.env.ORBOTO_PROJECT_KEY) {
|
|
18
|
+
const scope = envProjectScope(raw);
|
|
19
|
+
if (!scope)
|
|
20
|
+
return null;
|
|
21
|
+
const current = await client.get('/v1/agent/session', { instanceToken }).catch(() => null);
|
|
22
|
+
if (current && scopeDeclared(current.scope))
|
|
23
|
+
return current;
|
|
24
|
+
const row = await client.post('/v1/agent/heartbeat', { scope }, { instanceToken }).catch(() => null);
|
|
25
|
+
if (row && typeof row.sessionId === 'string')
|
|
26
|
+
rememberDeclaredScope(instanceToken, row.scope ?? scope);
|
|
27
|
+
return row;
|
|
28
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** ORB-2224 - the project a session declares from ORBOTO_PROJECT_KEY, and its drift against the CLI that writes it. */
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { declareEnvProjectScope, envProjectScope, PROJECT_KEY_ENV, scopeDeclared } from './project-scope.js';
|
|
5
|
+
import { _forgetDeclaredScopes, rememberedScope } from './session-scope-memory.js';
|
|
6
|
+
import { OrbotoClient } from './orboto-client.js';
|
|
7
|
+
import { makeSessionStartHandler } from './tools/session-start.js';
|
|
8
|
+
const read = (relative) => readFileSync(new URL(`../../../${relative}`, import.meta.url), 'utf8');
|
|
9
|
+
function fakeClient(row) {
|
|
10
|
+
const calls = [];
|
|
11
|
+
return {
|
|
12
|
+
calls,
|
|
13
|
+
get: async (path, opts) => { calls.push({ method: 'GET', path, instanceToken: opts?.instanceToken }); if (row instanceof Error)
|
|
14
|
+
throw row; return row; },
|
|
15
|
+
post: async (path, body, opts) => {
|
|
16
|
+
calls.push({ method: 'POST', path, body, instanceToken: opts?.instanceToken });
|
|
17
|
+
return { sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: body.scope };
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
beforeEach(() => { _forgetDeclaredScopes(); });
|
|
22
|
+
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); });
|
|
23
|
+
describe('ORB-2224 - ORBOTO_PROJECT_KEY', () => {
|
|
24
|
+
it('names the same variable the CLI writes into the entry and hands to the session', () => {
|
|
25
|
+
const go = read('cli/internal/cmd/project_scope.go');
|
|
26
|
+
expect(go).toContain(`const ProjectKeyEnv = "${PROJECT_KEY_ENV}"`);
|
|
27
|
+
expect(read('apps/mcp/src/project-scope.ts')).toContain(`process.env.${PROJECT_KEY_ENV}`);
|
|
28
|
+
expect(read('docs/env.md')).toContain(`| \`${PROJECT_KEY_ENV}\` | mcp |`);
|
|
29
|
+
});
|
|
30
|
+
it('reads a worker scope on the upper-cased key and ignores anything that is no key', () => {
|
|
31
|
+
expect(envProjectScope(' acme ')).toEqual({ role: 'worker', projectKeys: ['ACME'] });
|
|
32
|
+
for (const value of [undefined, '', ' ', 'acme widgets', '1ACME', '${ORBOTO_PROJECT_KEY}']) {
|
|
33
|
+
expect(envProjectScope(value)).toBeNull();
|
|
34
|
+
}
|
|
35
|
+
expect(scopeDeclared(null)).toBe(false);
|
|
36
|
+
expect(scopeDeclared({})).toBe(false);
|
|
37
|
+
expect(scopeDeclared({ role: 'worker' })).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
it('declares the project on a row without a scope and remembers it for the channel reconnect', async () => {
|
|
40
|
+
const client = fakeClient({ sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: null });
|
|
41
|
+
await declareEnvProjectScope(client, 'agent-abc', 'ACME');
|
|
42
|
+
expect(client.calls).toEqual([
|
|
43
|
+
{ method: 'GET', path: '/v1/agent/session', instanceToken: 'agent-abc' },
|
|
44
|
+
{ method: 'POST', path: '/v1/agent/heartbeat', body: { scope: { role: 'worker', projectKeys: ['ACME'] } }, instanceToken: 'agent-abc' },
|
|
45
|
+
]);
|
|
46
|
+
expect(rememberedScope('agent-abc')).toEqual({ role: 'worker', projectKeys: ['ACME'] });
|
|
47
|
+
});
|
|
48
|
+
it('declares on a token that has no row yet', async () => {
|
|
49
|
+
const client = fakeClient(new Error('404'));
|
|
50
|
+
await declareEnvProjectScope(client, 'agent-new', 'ACME');
|
|
51
|
+
expect(client.calls.map((c) => c.method)).toEqual(['GET', 'POST']);
|
|
52
|
+
});
|
|
53
|
+
it('keeps a scope the session already declares and does nothing without the variable', async () => {
|
|
54
|
+
const kept = fakeClient({ sessionId: 'x', scope: { role: 'integrator', projectKeys: ['ORB'] } });
|
|
55
|
+
await declareEnvProjectScope(kept, 'agent-kept', 'ACME');
|
|
56
|
+
expect(kept.calls.map((c) => c.method)).toEqual(['GET']);
|
|
57
|
+
const none = fakeClient({ sessionId: 'x', scope: null });
|
|
58
|
+
expect(await declareEnvProjectScope(none, 'agent-none', '')).toBeNull();
|
|
59
|
+
expect(none.calls).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
it('orboto_session_start without a scope declares the environment project on a row that has none', async () => {
|
|
62
|
+
vi.stubEnv(PROJECT_KEY_ENV, 'acme');
|
|
63
|
+
const beats = [];
|
|
64
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
65
|
+
const u = new URL(url.toString());
|
|
66
|
+
let body = {};
|
|
67
|
+
if (u.pathname === '/v1/agent/heartbeat') {
|
|
68
|
+
const sent = init?.body ? JSON.parse(init.body) : {};
|
|
69
|
+
beats.push(sent);
|
|
70
|
+
body = { sessionToken: 'x', sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: sent.scope ?? null };
|
|
71
|
+
}
|
|
72
|
+
else if (u.pathname === '/v1/agent/session') {
|
|
73
|
+
body = { sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: null };
|
|
74
|
+
}
|
|
75
|
+
else if (u.pathname === '/agent-instructions') {
|
|
76
|
+
body = { instructions: 'rules here', rulesHash: 'fixture' };
|
|
77
|
+
}
|
|
78
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => body, text: async () => '' };
|
|
79
|
+
});
|
|
80
|
+
const client = new OrbotoClient({ baseUrl: 'http://api.test', apiKey: 'k' });
|
|
81
|
+
const res = await makeSessionStartHandler(client)({}, { sessionId: 'scope1' });
|
|
82
|
+
expect(beats).toEqual([{}, { scope: { role: 'worker', projectKeys: ['ACME'] } }]);
|
|
83
|
+
expect(res.content[0].text).toContain('projects ACME');
|
|
84
|
+
expect(rememberedScope('mcp-scope1')).toEqual({ role: 'worker', projectKeys: ['ACME'] });
|
|
85
|
+
});
|
|
86
|
+
});
|
package/dist/server.js
CHANGED
|
@@ -28,6 +28,7 @@ import { agentMessageWorkToolConfig, makeAgentMessageWorkHandler } from './tools
|
|
|
28
28
|
import { agentMessagesToolConfig, makeAgentMessagesHandler } from './tools/agent-messages.js';
|
|
29
29
|
import { agentWakesToolConfig, makeAgentWakesHandler } from './tools/agent-wakes.js';
|
|
30
30
|
import { sessionCheckToolConfig, makeSessionCheckHandler } from './tools/session-check.js';
|
|
31
|
+
import { knowledgeAskToolConfig, makeKnowledgeAskHandler, knowledgeAddToolConfig, makeKnowledgeAddHandler } from './tools/knowledge.js';
|
|
31
32
|
import { listProjectsToolConfig, makeListProjectsHandler } from './tools/list-projects.js';
|
|
32
33
|
import { getProjectToolConfig, makeGetProjectHandler } from './tools/get-project.js';
|
|
33
34
|
import { projectReadinessToolConfig, makeProjectReadinessHandler } from './tools/project-readiness.js';
|
|
@@ -45,7 +46,7 @@ import { queryToolConfig, makeQueryHandler } from './tools/query.js';
|
|
|
45
46
|
import { customerReportToolConfig, makeCustomerReportHandler } from './tools/customer-report.js';
|
|
46
47
|
import { requirementsSpecToolConfig, makeRequirementsSpecHandler } from './tools/requirements-spec.js';
|
|
47
48
|
import { specCheckToolConfig, makeSpecCheckHandler } from './tools/spec-check.js';
|
|
48
|
-
import { wikiIngestUrlToolConfig, makeWikiIngestUrlHandler, wikiAskToolConfig, makeWikiAskHandler, wikiLintToolConfig, makeWikiLintHandler, wikiPlanUpdateToolConfig, makeWikiPlanUpdateHandler, wikiApplyPlanToolConfig, makeWikiApplyPlanHandler, wikiRecordToolConfig, makeWikiRecordHandler, wikiAppendSectionToolConfig, makeWikiAppendSectionHandler, wikiFlagStaleToolConfig, makeWikiFlagStaleHandler, wikiSaveAnswerToolConfig, makeWikiSaveAnswerHandler, } from './tools/wiki.js';
|
|
49
|
+
import { wikiIngestUrlToolConfig, makeWikiIngestUrlHandler, wikiAskToolConfig, makeWikiAskHandler, wikiLintToolConfig, makeWikiLintHandler, wikiPlanUpdateToolConfig, makeWikiPlanUpdateHandler, wikiApplyPlanToolConfig, makeWikiApplyPlanHandler, wikiRecordToolConfig, makeWikiRecordHandler, wikiAppendSectionToolConfig, makeWikiAppendSectionHandler, wikiFlagStaleToolConfig, makeWikiFlagStaleHandler, wikiSaveAnswerToolConfig, makeWikiSaveAnswerHandler, wikiRunsToolConfig, makeWikiRunsHandler } from './tools/wiki.js';
|
|
49
50
|
import { personalFactListToolConfig, makePersonalFactListHandler, personalFactAddToolConfig, makePersonalFactAddHandler, personalFactUpdateToolConfig, makePersonalFactUpdateHandler, personalFactDeleteToolConfig, makePersonalFactDeleteHandler, } from './tools/personal-facts.js';
|
|
50
51
|
import { listDocSpacesToolConfig, makeListDocSpacesHandler, getDocToolConfig, makeGetDocHandler, createDocSpaceToolConfig, makeCreateDocSpaceHandler, updateDocSpaceToolConfig, makeUpdateDocSpaceHandler, deleteDocSpaceToolConfig, makeDeleteDocSpaceHandler, listDocsInSpaceToolConfig, makeListDocsInSpaceHandler, createDocToolConfig, makeCreateDocHandler, updateDocToolConfig, makeUpdateDocHandler, deleteDocToolConfig, makeDeleteDocHandler, moveDocToolConfig, makeMoveDocHandler, duplicateDocSpaceToolConfig, makeDuplicateDocSpaceHandler, resolveDocSmartLinksToolConfig, makeResolveDocSmartLinksHandler, } from './tools/docs.js';
|
|
51
52
|
import { searchDocsToolConfig, makeSearchDocsHandler, editDocToolConfig, makeEditDocHandler, editDocSectionToolConfig, makeEditDocSectionHandler, } from './tools/doc-edits.js';
|
|
@@ -211,6 +212,8 @@ export async function buildOrbotoMcpServer(opts) {
|
|
|
211
212
|
reg('orboto_messages', agentMessagesToolConfig, makeAgentMessagesHandler(client));
|
|
212
213
|
reg('orboto_agent_wakes', agentWakesToolConfig, makeAgentWakesHandler(client));
|
|
213
214
|
reg('orboto_session_check', sessionCheckToolConfig, makeSessionCheckHandler(client));
|
|
215
|
+
reg('orboto_knowledge_ask', knowledgeAskToolConfig, makeKnowledgeAskHandler(client));
|
|
216
|
+
reg('orboto_knowledge_add', knowledgeAddToolConfig, makeKnowledgeAddHandler(client));
|
|
214
217
|
reg('orboto_agent_broadcast', agentBroadcastToolConfig, makeAgentBroadcastHandler(client));
|
|
215
218
|
reg('orboto_list_projects', listProjectsToolConfig, makeListProjectsHandler(client));
|
|
216
219
|
reg('orboto_get_project', getProjectToolConfig, makeGetProjectHandler(client));
|
|
@@ -269,6 +272,7 @@ export async function buildOrbotoMcpServer(opts) {
|
|
|
269
272
|
reg('orboto_wiki_ingest_url', wikiIngestUrlToolConfig, makeWikiIngestUrlHandler(client));
|
|
270
273
|
reg('orboto_wiki_ask', wikiAskToolConfig, makeWikiAskHandler(client));
|
|
271
274
|
reg('orboto_wiki_lint', wikiLintToolConfig, makeWikiLintHandler(client));
|
|
275
|
+
reg('orboto_wiki_runs', wikiRunsToolConfig, makeWikiRunsHandler(client));
|
|
272
276
|
reg('orboto_wiki_plan_update', wikiPlanUpdateToolConfig, makeWikiPlanUpdateHandler(client));
|
|
273
277
|
reg('orboto_wiki_apply_plan', wikiApplyPlanToolConfig, makeWikiApplyPlanHandler(client));
|
|
274
278
|
reg('orboto_wiki_record', wikiRecordToolConfig, makeWikiRecordHandler(client));
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { z } from 'zod';
|
|
12
12
|
export const listAgentDriftToolConfig = {
|
|
13
13
|
title: 'List agent drift events',
|
|
14
|
-
description: 'List agent drift events
|
|
14
|
+
description: 'List agent drift events newest first, with aggregate metrics. A drift event is a commit with no ticket key and no timer, or a ticket transition without a summary comment. Filter by `userId`, `driftType` (untracked_commit | transition_without_summary), `from`/`to` ISO dates, and `resolved`. The response `enabled` flag is false when the operator has not turned drift detection on. Requires `admin:agent_drift:read`.',
|
|
15
15
|
inputSchema: z.object({
|
|
16
16
|
userId: z.string().uuid().optional().describe('Filter to one user.'),
|
|
17
17
|
driftType: z.enum(['untracked_commit', 'transition_without_summary']).optional(),
|
|
@@ -18,7 +18,7 @@ function renderBlock(b) {
|
|
|
18
18
|
}
|
|
19
19
|
export const listAgentInstructionsToolConfig = {
|
|
20
20
|
title: 'MANAGE the workspace coding-agent rule blocks (admin)',
|
|
21
|
-
description: 'ADMIN
|
|
21
|
+
description: 'ADMIN tool: lists the rule BLOCKS at a scope for editing, not the rules to follow. Scopes: workspace needs admin:ai:read, project needs project:edit, personal is your own; blocks can be edited, toggled and reordered. This is NOT how you read the rules to follow. To LOAD the rules you must follow as an agent, call orboto_session_start instead - it returns the complete assembled rule set.',
|
|
22
22
|
inputSchema: z.object({
|
|
23
23
|
scope: z.enum(['workspace', 'customer', 'project', 'personal']).default('workspace'),
|
|
24
24
|
projectId: z.string().uuid().optional().describe('Required for scope=project.'),
|
|
@@ -72,7 +72,7 @@ export function makeListAgentInstructionsHandler(client) {
|
|
|
72
72
|
}
|
|
73
73
|
export const createAgentInstructionToolConfig = {
|
|
74
74
|
title: 'Add a custom coding-agent rule block',
|
|
75
|
-
description: 'Create a custom rule block at a scope
|
|
75
|
+
description: 'Create a custom rule block at a scope (workspace, customer, project or personal). Workspace reaches every agent (admin:ai:write), customer every project of one customer (customer:write), project one project (project:edit), personal only you. For scope=customer pass customerId; for scope=project pass projectId.'
|
|
76
76
|
+ RULE_WRITING_CONTRACT,
|
|
77
77
|
inputSchema: z.object({
|
|
78
78
|
title: z.string().min(1).max(120),
|
|
@@ -13,7 +13,7 @@ import { mcpInstanceToken } from './shared.js';
|
|
|
13
13
|
const PartySchema = z.object({ userId: z.string(), email: z.string(), sessionId: z.string().nullable(), role: z.string().nullable(), label: z.string() });
|
|
14
14
|
export const agentMessagesToolConfig = {
|
|
15
15
|
title: 'Agent inbox and message work',
|
|
16
|
-
description: 'Your inbox as THIS session sees it
|
|
16
|
+
description: 'Your inbox as THIS session sees it. It holds mail to this session, account mail inside your declared scope, broadcasts; sibling-session mail and own sends stay out, all:true shows the whole account (ORB-2136). messageWork carries durable work ownership - claim before executing. ackIds acks: it completes an unclaimed request; claimed work with open steps is refused until messageWork completes or blocks it. dismiss is the third answer: not_mine hides it here and tells the sender, obsolete/duplicate close it for the account (note or duplicateOf required). Never leave mail you read and judged. Reply via orboto_agent_notify with threadId (toSessionRef = from.sessionId reaches that instance).',
|
|
17
17
|
inputSchema: z.object({
|
|
18
18
|
messageWork: z.record(z.unknown()).optional()
|
|
19
19
|
.describe('Work envelope {messageId, mutation, cursor, limit, openOnly}; schema: orboto_api_search.'),
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
export const agentWakesToolConfig = {
|
|
7
7
|
title: 'Wake ledger',
|
|
8
|
-
description: '
|
|
8
|
+
description: 'The wake ledger: who woke this account\'s sessions and at what cost. It names the visibility rule that delivered the message (addressed | scope_project | scope_ticket | broadcast | unscoped_session | account_view), what the woken session did with it (handled | not_mine | obsolete | duplicate | expired | pending) and what the wake cost in context tokens. `stats: true` returns delivery precision per woken session and per sender over `hours` (operator read, needs admin:agents:read); `wakes`, `precision` and the median latency count deliveries (`channel` | `follow` | `poll`) only, `backlogSeen` is the separate count of `nudge` rows - a backlog sighting, not a wake. Without `stats` it returns the ledger rows of your own account, newest first. Use it to prove a misrouting sender instead of describing it in prose.',
|
|
9
9
|
inputSchema: z.object({
|
|
10
10
|
stats: z.boolean().default(false).describe('Precision per session and per sender instead of rows.'),
|
|
11
11
|
hours: z.number().int().min(1).max(720).optional().describe('Stats window, default 24.'),
|
package/dist/tools/backup.js
CHANGED
|
@@ -69,7 +69,7 @@ export function makeListBackupsHandler(client) {
|
|
|
69
69
|
}
|
|
70
70
|
export const downloadBackupToolConfig = {
|
|
71
71
|
title: 'Download a stored backup run',
|
|
72
|
-
description: 'Download a stored backup run by run id
|
|
72
|
+
description: 'Download a stored backup run by run id as a base64 resource attachment. The id comes from orboto_list_backups; an encrypted run downloads sealed and needs its key to restore. Requires admin:backup:read.',
|
|
73
73
|
inputSchema: z.object({
|
|
74
74
|
runId: z.string().uuid().describe('Backup run id from orboto_list_backups.'),
|
|
75
75
|
}).shape,
|
|
@@ -22,7 +22,7 @@ const TicketDraftSchema = z.object({
|
|
|
22
22
|
});
|
|
23
23
|
export const bulkCreateTicketsToolConfig = {
|
|
24
24
|
title: 'Create many tickets in one call',
|
|
25
|
-
description: 'Create up to 50 tickets in one call
|
|
25
|
+
description: 'Create up to 50 tickets in one call. It is the right tool whenever you are about to create more than ~3 tickets (milestone planning, epic breakdown, import). Per-draft error isolation: a rejected draft (bad label, language block, hard duplicate-block) is reported in `failed` while the rest are still created. Duplicate detection runs per draft and is reported compactly - one line per flagged draft in `duplicateFlags`; review those before treating them as new work. A call-level `milestone` / `parentTicketKey` applies to every draft unless the draft overrides it. Drafts are created in array order, so a draft can NOT reference a sibling draft as parent - create parents first (or in a first call).',
|
|
26
26
|
inputSchema: z.object({
|
|
27
27
|
projectKey: z.string().min(1).describe('Project key - all drafts land here.'),
|
|
28
28
|
tickets: z.array(TicketDraftSchema).min(1).max(50).describe('The ticket drafts, created in order.'),
|
|
@@ -137,7 +137,7 @@ export function makeBulkCreateTicketsHandler(client) {
|
|
|
137
137
|
}
|
|
138
138
|
export const bulkAddTicketDependenciesToolConfig = {
|
|
139
139
|
title: 'Add many ticket dependencies in one call',
|
|
140
|
-
description: 'Create up to 200 blocked-by edges in one call
|
|
140
|
+
description: 'Create up to 200 blocked-by edges in one call. Use it instead of repeated orboto_add_ticket_dependency whenever wiring more than ~3 edges (dependency graphs after a bulk create). Each pair is (ticketKey, dependsOnKey) = "ticket is blocked by dependsOn". Per-pair error isolation; an already-existing edge counts as ok, a cycle rejection lands in `failed` with the API\'s reason.',
|
|
141
141
|
inputSchema: z.object({
|
|
142
142
|
pairs: z.array(z.object({
|
|
143
143
|
ticketKey: z.string().min(3).describe('The blocked ticket.'),
|
|
@@ -6,7 +6,7 @@ import { trimSimilarEntries } from './similar-projection.js';
|
|
|
6
6
|
import { resolveProjectByKey, resolveTicketByKey } from './shared.js';
|
|
7
7
|
export const checkSimilarToolConfig = {
|
|
8
8
|
title: 'Check for similar tickets before creating',
|
|
9
|
-
description: 'Run the duplicate
|
|
9
|
+
description: 'Run the duplicate check against a proposed title + description without creating anything. The pipeline is tsvector plus an AI-embedding rerank when configured. Returns up to `limit` candidates ranked by similarity. Use this BEFORE `orboto_create_ticket` when you want to confirm a feature is not already tracked - if a high-similarity candidate exists, prefer to comment on / claim / extend it instead of opening a new ticket. Empty result = safe to create. `orboto_create_ticket` runs the same check after the fact and surfaces `similarWarnings` in its response, so this tool is optional but cheaper than a create-then-close round trip. Only an AI-embedding candidate carries an absolute similarity; a full-text candidate prints as "text match" without a percentage (its rank is relative, so the best text match always scores 1.000) and can never trigger the hard duplicate-block, and neither can a match against a CLOSED ticket (ORB-2185). An empty result from a LONG, solution-framed title is weak evidence - detection ranks by term co-occurrence, so also probe with a single distinctive STABLE token (file/component/error-string fragment) and the SYMPTOM wording, not just your intended fix.',
|
|
10
10
|
inputSchema: z.object({
|
|
11
11
|
projectKey: z.string().min(1).describe('Project key (e.g. "ACME").'),
|
|
12
12
|
title: z.string().min(1).describe('Proposed ticket title.'),
|
|
@@ -11,7 +11,7 @@ import { z } from 'zod';
|
|
|
11
11
|
import { resolveTicketByKey } from './shared.js';
|
|
12
12
|
export const draftCustomerReplyToolConfig = {
|
|
13
13
|
title: 'Draft a customer-facing reply for a ticket',
|
|
14
|
-
description: 'Draft a reply
|
|
14
|
+
description: 'Draft a customer-facing reply about a ticket (by key). It is built server-side from customer-safe facts only (key, title, status, due date, customer summary, non-internal comments, your `ask`) - never from internal notes. Returns the draft, the facts used and what was excluded. Never posts: show it to the user, then `orboto_comment` only after approval. Use it for every customer-facing text.',
|
|
15
15
|
inputSchema: z.object({
|
|
16
16
|
ticketKey: z.string().describe('Ticket key like "ORB-42".'),
|
|
17
17
|
ask: z.string().min(3).max(1000).describe('What the reply should do, in the user\'s words, e.g. "tell them the fix ships with the next release" or "ask for the browser version".'),
|
|
@@ -7,7 +7,7 @@ import { z } from 'zod';
|
|
|
7
7
|
import { resolveProjectByKey } from './shared.js';
|
|
8
8
|
export const getProjectPrimerToolConfig = {
|
|
9
9
|
title: 'Get project primer (AI Context Pack)',
|
|
10
|
-
description: 'Returns the project\'s auto-generated session primer in
|
|
10
|
+
description: 'Returns the project\'s auto-generated session primer in one call. It carries the project vocabulary a create has to get right (ticket type enum, the project\'s labels, its status names per category, its open milestone keys), active milestones, ticket counts, recently-closed milestones, top docs, structured primer facts, the Knowledge block (the index of the project wiki and of the Operations wiki, their latest log entries and open lint issues), and recent activity. Primer facts merge three scopes in order Workspace -> Project -> Personal: the Personal block ("Your preferences") is the calling user\'s own personal-scope facts, included only when that user has opted in (ai_preferences_enabled) and never showing another user\'s. Repo briefings (CLAUDE.md / AGENTS.md) are included ONLY when the operator configured them AND the API host can read those files from disk - most deployments will not have them, so do not assume CLAUDE.md / AGENTS.md content is in the response. Token-budget aware - pass `maxTokens` to constrain the output. Input is the project key (e.g. "ORB"), case-insensitive.',
|
|
11
11
|
inputSchema: z.object({
|
|
12
12
|
projectKey: z.string().min(1).describe('Project key, case-insensitive.'),
|
|
13
13
|
maxTokens: z.number().int().positive().max(200000).optional()
|
package/dist/tools/help.js
CHANGED
|
@@ -15,7 +15,7 @@ import { onboardingText } from '../agent-head.js';
|
|
|
15
15
|
const TOPICS = { connect: connectScriptText, onboarding: onboardingText };
|
|
16
16
|
export const helpToolConfig = {
|
|
17
17
|
title: 'Full guidance for one orboto tool',
|
|
18
|
-
description: 'Return the complete guidance text
|
|
18
|
+
description: 'Return the complete guidance text for one orboto tool by name. Manifest descriptions are one-line summaries; this adds the workflows, warnings and edge cases. Call it before first use of an unfamiliar write tool. topic: "connect" returns the step-by-step script for setting a person up, topic: "onboarding" the first five minutes and the work loop.',
|
|
19
19
|
inputSchema: z.object({
|
|
20
20
|
tool: z.string().min(1).max(128).optional().describe('Tool name, e.g. orboto_create_ticket.'),
|
|
21
21
|
topic: z.enum(['connect', 'onboarding']).optional().describe('Guidance topic instead of a tool.'),
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** ORB-2225 - the wiki as the agents' knowledge base: ask it first, file durable facts in it. */
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { mcpInstanceToken } from './shared.js';
|
|
4
|
+
export const knowledgeAskToolConfig = {
|
|
5
|
+
title: 'Ask the wiki',
|
|
6
|
+
description: 'Ask your session\'s project wikis and the Operations wiki before asking a person; cite docs by key.',
|
|
7
|
+
inputSchema: z.object({ question: z.string().min(3).max(1000), projectKeys: z.array(z.string().min(1).max(32)).max(20).optional() }).shape,
|
|
8
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
9
|
+
};
|
|
10
|
+
export function makeKnowledgeAskHandler(client) {
|
|
11
|
+
return async (input, extra) => {
|
|
12
|
+
const res = await client.post('/knowledge/ask', input, { instanceToken: mcpInstanceToken(undefined, extra) });
|
|
13
|
+
const where = res.spaces.map((s) => s.spaceKey ?? s.spaceName).join(', ');
|
|
14
|
+
const cites = res.citations.map((c) => `[${c.index}] ${c.title} - ${c.link}`).join('\n');
|
|
15
|
+
return {
|
|
16
|
+
content: [{ type: 'text', text: [res.answer, cites ? `Sources:\n${cites}` : '', `Asked: ${where}.`].filter(Boolean).join('\n\n') }],
|
|
17
|
+
structuredContent: res,
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export const knowledgeAddToolConfig = {
|
|
22
|
+
title: 'File a fact in the wiki',
|
|
23
|
+
description: 'File a durable fact in a project wiki (projectKey) or the Operations wiki, never only in local memory.',
|
|
24
|
+
inputSchema: z.object({
|
|
25
|
+
title: z.string().min(1).max(200), content: z.string().min(1).max(50_000),
|
|
26
|
+
projectKey: z.string().min(1).max(32).optional(), operations: z.boolean().optional(),
|
|
27
|
+
}).shape,
|
|
28
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
29
|
+
};
|
|
30
|
+
export function makeKnowledgeAddHandler(client) {
|
|
31
|
+
return async (input, extra) => {
|
|
32
|
+
const res = await client.post('/knowledge/add', input, { instanceToken: mcpInstanceToken(undefined, extra) });
|
|
33
|
+
return { content: [{ type: 'text', text: res.text }], structuredContent: res };
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { OrbotoClient as RealClient } from '../orboto-client.js';
|
|
3
|
+
import { knowledgeAddToolConfig, knowledgeAskToolConfig, makeKnowledgeAddHandler, makeKnowledgeAskHandler } from './knowledge.js';
|
|
4
|
+
import { KNOWLEDGE_BLOCK_CHARS, knowledgeLines, makeSessionStartHandler } from './session-start.js';
|
|
5
|
+
import { makeResponseExpandHandler } from './response-expand.js';
|
|
6
|
+
import { makeWikiRunsHandler } from './wiki.js';
|
|
7
|
+
function textOf(result) {
|
|
8
|
+
return result.content[0].text;
|
|
9
|
+
}
|
|
10
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
11
|
+
describe('ORB-2225 orboto_knowledge_ask and orboto_knowledge_add', () => {
|
|
12
|
+
it('asks the scope and lists the cited docs and the asked spaces', async () => {
|
|
13
|
+
const post = vi.fn().mockResolvedValue({
|
|
14
|
+
answer: 'Nightly with restic [1].', citations: [{ index: 1, title: 'Backup chain', link: '/spaces/s/docs/d' }], mode: 'keyword', abstained: false,
|
|
15
|
+
source: 'session', spaces: [{ spaceKey: 'ACME-S1', spaceName: 'ACME', projectKey: 'ACME' }, { spaceKey: 'SPACE-3', spaceName: 'Operations', projectKey: null }],
|
|
16
|
+
});
|
|
17
|
+
const result = await makeKnowledgeAskHandler({ post })({ question: 'where does the backup run?' }, { sessionId: 'abc' });
|
|
18
|
+
expect(post).toHaveBeenCalledWith('/knowledge/ask', { question: 'where does the backup run?' }, { instanceToken: 'mcp-abc' });
|
|
19
|
+
expect(textOf(result)).toContain('[1] Backup chain - /spaces/s/docs/d');
|
|
20
|
+
expect(textOf(result)).toContain('Asked: ACME-S1, SPACE-3.');
|
|
21
|
+
});
|
|
22
|
+
it('files a fact and returns the server sentence naming where it landed', async () => {
|
|
23
|
+
const post = vi.fn().mockResolvedValue({ action: 'recorded', text: 'Recorded in the ACME-S1 wiki: ACME-D7 "Build host". Cite it by key.', pages: [], source: null });
|
|
24
|
+
const result = await makeKnowledgeAddHandler({ post })({ title: 'Build host', content: 'build.example.org', projectKey: 'ACME' });
|
|
25
|
+
expect(post.mock.calls[0][0]).toBe('/knowledge/add');
|
|
26
|
+
expect(textOf(result)).toContain('ACME-D7');
|
|
27
|
+
expect(result.structuredContent).toMatchObject({ action: 'recorded' });
|
|
28
|
+
});
|
|
29
|
+
it('both tools carry a title and annotations; ask is read-only, add is not', () => {
|
|
30
|
+
expect(knowledgeAskToolConfig.title).toBeTruthy();
|
|
31
|
+
expect(knowledgeAskToolConfig.annotations.readOnlyHint).toBe(true);
|
|
32
|
+
expect(knowledgeAddToolConfig.title).toBeTruthy();
|
|
33
|
+
expect(knowledgeAddToolConfig.annotations.readOnlyHint).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
describe('ORB-2225 the Knowledge block of orboto_session_start', () => {
|
|
37
|
+
it('prints the block from GET /knowledge with the session header', async () => {
|
|
38
|
+
const seen = [];
|
|
39
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
40
|
+
const u = new URL(url.toString());
|
|
41
|
+
const headers = new Headers(init?.headers);
|
|
42
|
+
seen.push({ path: u.pathname, session: headers.get('x-orboto-agent-session') });
|
|
43
|
+
const body = u.pathname === '/agent-instructions' ? { instructions: 'claim -> commit -> close', rulesHash: 'fixture' } : u.pathname === '/knowledge'
|
|
44
|
+
? { source: 'session', spaces: [{ spaceKey: 'ACME-S1', spaceName: 'ACME', projectKey: 'ACME', pages: 3, sources: 1, openLintIssues: 0, indexDoc: { key: 'ACME-D1' } }], missing: [], text: '## Knowledge\nIndex ACME-D1:\n- Build host' }
|
|
45
|
+
: {};
|
|
46
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => body, text: async () => '' };
|
|
47
|
+
});
|
|
48
|
+
const client = new RealClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
49
|
+
const result = await makeSessionStartHandler(client)({}, { sessionId: 'k1' });
|
|
50
|
+
expect(seen.find((c) => c.path === '/knowledge')?.session).toBe('mcp-k1');
|
|
51
|
+
expect(textOf(result)).toContain('## Knowledge\nIndex ACME-D1:\n- Build host');
|
|
52
|
+
expect(result.structuredContent).toMatchObject({ knowledge: { source: 'session', spaces: [{ spaceKey: 'ACME-S1', indexDocKey: 'ACME-D1', pages: 3 }] } });
|
|
53
|
+
});
|
|
54
|
+
it('cuts a long block at a line and serves the rest through a handle', async () => {
|
|
55
|
+
const text = ['## Knowledge', ...Array.from({ length: 200 }, (_, i) => `- ACME-D${i} Page ${i}`)].join('\n');
|
|
56
|
+
const block = knowledgeLines(text);
|
|
57
|
+
expect(block.lines.join('\n').length).toBeLessThan(KNOWLEDGE_BLOCK_CHARS + 200);
|
|
58
|
+
expect(block.lines.at(-1)).toContain(`handle: "${block.handle}"`);
|
|
59
|
+
const expanded = await makeResponseExpandHandler()({ handle: block.handle, path: 'knowledge' });
|
|
60
|
+
expect(textOf(expanded)).toContain('- ACME-D199 Page 199');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
describe('ORB-2225 orboto_wiki_runs', () => {
|
|
64
|
+
it('lists the failed runs with their error and retries one by id', async () => {
|
|
65
|
+
const get = vi.fn().mockResolvedValue({ runs: [{ id: 'r1', sourceKey: 'ACME-D9', sourceTitle: 'Runner notes', status: 'failed', error: 'answer budget spent', createdAt: '2026-09-27T08:00:00Z' }] });
|
|
66
|
+
const post = vi.fn().mockResolvedValue({ runId: 'r1', status: 'applied', queued: false, error: null });
|
|
67
|
+
const handler = makeWikiRunsHandler({ get, post });
|
|
68
|
+
expect(textOf(await handler({ spaceId: 'ACME-S1', status: 'failed' }))).toContain('r1 failed ACME-D9 "Runner notes" 2026-09-27T08:00:00Z - answer budget spent');
|
|
69
|
+
expect(get).toHaveBeenCalledWith('/spaces/ACME-S1/llm-wiki/ingest-runs?status=failed');
|
|
70
|
+
expect((await handler({ spaceId: 'ACME-S1', action: 'retry' })).isError).toBe(true);
|
|
71
|
+
expect(textOf(await handler({ spaceId: 'ACME-S1', action: 'retry', runId: '11111111-0000-4000-8000-000000000000' }))).toContain('ran again: applied');
|
|
72
|
+
expect(post).toHaveBeenCalledWith('/spaces/ACME-S1/llm-wiki/ingest-runs/11111111-0000-4000-8000-000000000000/retry', {});
|
|
73
|
+
});
|
|
74
|
+
});
|
package/dist/tools/sentry.js
CHANGED
|
@@ -9,7 +9,7 @@ import { mcpInstanceToken, resolveProjectByKey, resolveTicketByKey } from './sha
|
|
|
9
9
|
const ACTIONS = ['list', 'health', 'connect', 'update', 'rotate_secret', 'disconnect', 'triage_lane', 'verdict'];
|
|
10
10
|
export const sentryToolConfig = {
|
|
11
11
|
title: 'Sentry connector',
|
|
12
|
-
description: 'Sentry connector (ORB-2123): list, health, connect (orgSlug, projectSlug, clientSecret), update (triageMode comment|fix_branch), rotate_secret, disconnect, triage_lane (laneId, empty unbinds), verdict (ticketKey, verdict known_fixed|regression|new, summary). Secrets never come back; docs/sentry-intake.md.',
|
|
12
|
+
description: 'Sentry connector (ORB-2123). Actions: list, health, connect (orgSlug, projectSlug, clientSecret), update (triageMode comment|fix_branch), rotate_secret, disconnect, triage_lane (laneId, empty unbinds), verdict (ticketKey, verdict known_fixed|regression|new, summary). Secrets never come back; docs/sentry-intake.md.',
|
|
13
13
|
inputSchema: z.object({
|
|
14
14
|
action: z.enum(ACTIONS),
|
|
15
15
|
projectKey: z.string().min(1),
|
|
@@ -16,11 +16,13 @@ import { loadRequiredRules } from '../required-rules.js';
|
|
|
16
16
|
import { GIT_HEALTH_REASON_TEXT } from './git-health-reasons.js';
|
|
17
17
|
import { channelStartLines } from '../inbox-channel.js';
|
|
18
18
|
import { rememberDeclaredScope } from '../session-scope-memory.js';
|
|
19
|
+
import { declareEnvProjectScope, envProjectScope } from '../project-scope.js';
|
|
19
20
|
import { agentHeadText } from '../agent-head.js';
|
|
20
21
|
export const sessionStartToolConfig = {
|
|
21
22
|
title: 'Load the rules you must follow + re-orient',
|
|
22
23
|
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.) Registers this session (a heartbeat on its instance row) and is otherwise read-only. '
|
|
23
|
-
+ '**Parameters.** `rulesOnly: true` returns ONLY the complete rules text (nothing else) and is never truncated - the cheapest way to read the rules the index listed. `projectId` adds that project\'s rules on top of the workspace + personal ones. `ticketKey` ("ACME-42") bundles that ticket\'s primer, full detail, dependencies, checklists, git health and other active agent sessions into the same response. `forceRules: true` returns the full rules text inline with the rest of the digest even when this connection already delivered them - use it whenever the rules are NOT in your context right now: after a compaction, a /clear, or a fresh agent taking over an existing connection. `agentKind` (coding, orchestrator, reviewer, runner) and `modelTier` (frontier, standard, small) are your self-declared classification; rule blocks and per-tier rule text are targeted by them. `scope` { projectKeys, ticketKeys, role } declares this session\'s responsibility; the returned session.ref is what peers pass as toSessionRef (ORB-2136).'
|
|
24
|
+
+ '**Parameters.** `rulesOnly: true` returns ONLY the complete rules text (nothing else) and is never truncated - the cheapest way to read the rules the index listed. `projectId` adds that project\'s rules on top of the workspace + personal ones. `ticketKey` ("ACME-42") bundles that ticket\'s primer, full detail, dependencies, checklists, git health and other active agent sessions into the same response. `forceRules: true` returns the full rules text inline with the rest of the digest even when this connection already delivered them - use it whenever the rules are NOT in your context right now: after a compaction, a /clear, or a fresh agent taking over an existing connection. `agentKind` (coding, orchestrator, reviewer, runner) and `modelTier` (frontier, standard, small) are your self-declared classification; rule blocks and per-tier rule text are targeted by them. `scope` { projectKeys, ticketKeys, role } declares this session\'s responsibility; the returned session.ref is what peers pass as toSessionRef (ORB-2136). '
|
|
25
|
+
+ 'The digest also carries the Knowledge block (ORB-2225): the wiki index of your declared projects and of the Operations wiki, their latest log entries and open lint issues; ask the wiki with orboto_knowledge_ask before asking a person, file durable facts with orboto_knowledge_add.',
|
|
24
26
|
inputSchema: z.object({
|
|
25
27
|
projectId: z.string().uuid().optional().describe('Also load this project\'s rules.'),
|
|
26
28
|
ticketKey: z.string().min(3).optional().describe('Ticket key ("ACME-42") - bundles that ticket\'s full context.'),
|
|
@@ -131,6 +133,30 @@ async function buildTicketBundle(client, ticketKey) {
|
|
|
131
133
|
},
|
|
132
134
|
};
|
|
133
135
|
}
|
|
136
|
+
/** ORB-2225 - the Knowledge block stays small; the rest is one expand away. */
|
|
137
|
+
export const KNOWLEDGE_BLOCK_CHARS = 1500;
|
|
138
|
+
export function knowledgeLines(text) {
|
|
139
|
+
if (text.length <= KNOWLEDGE_BLOCK_CHARS)
|
|
140
|
+
return { lines: text.split('\n') };
|
|
141
|
+
const cut = text.slice(0, KNOWLEDGE_BLOCK_CHARS);
|
|
142
|
+
const kept = cut.slice(0, Math.max(cut.lastIndexOf('\n'), 0));
|
|
143
|
+
const handle = storePayload('orboto_session_start', {
|
|
144
|
+
structuredContent: { knowledge: text }, text,
|
|
145
|
+
omitted: [{ path: 'knowledge', kind: 'string', omittedChars: text.length - kept.length }],
|
|
146
|
+
});
|
|
147
|
+
return {
|
|
148
|
+
handle,
|
|
149
|
+
lines: [...kept.split('\n'), `(Knowledge block cut at ${kept.length} of ${text.length} characters: orboto_response_expand { handle: "${handle}", path: "knowledge" }, or GET /knowledge.)`],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function knowledgeSummary(index, handle) {
|
|
153
|
+
return {
|
|
154
|
+
source: index.source,
|
|
155
|
+
spaces: index.spaces.map((s) => ({ spaceKey: s.spaceKey ?? s.spaceName, projectKey: s.projectKey, indexDocKey: s.indexDoc?.key ?? null, pages: s.pages, sources: s.sources, openLintIssues: s.openLintIssues })),
|
|
156
|
+
missing: index.missing,
|
|
157
|
+
...(handle ? { handle } : {}),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
134
160
|
/** ORB-2209 - a declared scope stays with the instance token; the agent declares it once per terminal. */
|
|
135
161
|
export function identityLine(registration) {
|
|
136
162
|
const count = registration.reconnects ?? 0;
|
|
@@ -175,12 +201,16 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
175
201
|
},
|
|
176
202
|
};
|
|
177
203
|
}
|
|
178
|
-
const
|
|
179
|
-
|
|
204
|
+
const toRegistration = (r) => (r && typeof r.sessionId === 'string' ? { sessionId: r.sessionId, scope: r.scope ?? null, reconnects: r.reconnects, lastReconnectAt: r.lastReconnectAt } : null);
|
|
205
|
+
let registration = await client.post('/v1/agent/heartbeat', input.scope !== undefined ? { scope: input.scope } : {}, { instanceToken })
|
|
206
|
+
.then(toRegistration)
|
|
180
207
|
.catch(() => null);
|
|
181
208
|
if (input.scope !== undefined && registration)
|
|
182
209
|
rememberDeclaredScope(instanceToken, registration.scope);
|
|
183
|
-
|
|
210
|
+
if (input.scope === undefined && registration && !registration.scope && envProjectScope()) {
|
|
211
|
+
registration = toRegistration(await declareEnvProjectScope(client, instanceToken)) ?? registration;
|
|
212
|
+
}
|
|
213
|
+
const [me, rules, assigned, timer, inboxRaw, knowledge] = await Promise.all([
|
|
184
214
|
client.get('/users/me').catch(() => null),
|
|
185
215
|
loadRequiredRules(client, rulesPath, rulesParams.get('knownRulesHash') ?? undefined),
|
|
186
216
|
client.get('/users/me/assigned-tickets?statuses=IN_PROGRESS,IN_REVIEW&limit=20').catch(() => ({ items: [] })),
|
|
@@ -188,6 +218,9 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
188
218
|
client.get('/v1/agent/messages?limit=10', { instanceToken })
|
|
189
219
|
.then((r) => (Array.isArray(r?.messages) ? r.messages : []))
|
|
190
220
|
.catch(() => []),
|
|
221
|
+
client.get('/knowledge', { instanceToken })
|
|
222
|
+
.then((r) => (typeof r?.text === 'string' && Array.isArray(r.spaces) && Array.isArray(r.missing) ? r : null))
|
|
223
|
+
.catch(() => null),
|
|
191
224
|
]);
|
|
192
225
|
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 }));
|
|
193
226
|
const tickets = Array.isArray(assigned) ? assigned : (assigned?.items ?? []);
|
|
@@ -262,6 +295,9 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
262
295
|
}
|
|
263
296
|
lines.push('', '## Timer');
|
|
264
297
|
lines.push(timer?.ticketId ? `Running on ${timer.ticketKey ?? timer.ticketId} since ${timer.startedAt ?? 'earlier'}.` : 'No timer running.');
|
|
298
|
+
const knowledgeBlock = knowledge?.text ? knowledgeLines(knowledge.text) : null;
|
|
299
|
+
if (knowledgeBlock)
|
|
300
|
+
lines.push('', ...knowledgeBlock.lines);
|
|
265
301
|
lines.push('', '## This session');
|
|
266
302
|
const scopeText = registration?.scope
|
|
267
303
|
? [registration.scope.role ? `role ${registration.scope.role}` : null, registration.scope.projectKeys?.length ? `projects ${registration.scope.projectKeys.join(', ')}` : null, registration.scope.ticketKeys?.length ? `tickets ${registration.scope.ticketKeys.join(', ')}` : null].filter(Boolean).join('; ')
|
|
@@ -310,6 +346,7 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
310
346
|
.filter((p) => p.connections.length > 0),
|
|
311
347
|
healthyCount: gitHealthWithConnections.reduce((n, p) => n + p.connections.filter((c) => c.healthy).length, 0),
|
|
312
348
|
},
|
|
349
|
+
...(knowledge ? { knowledge: knowledgeSummary(knowledge, knowledgeBlock?.handle) } : {}),
|
|
313
350
|
...(pendingMessages.length > 0 ? { pendingMessages } : {}),
|
|
314
351
|
...(bundle ? { ticketBundle: bundle.structured } : {}),
|
|
315
352
|
},
|
package/dist/tools/wiki.js
CHANGED
|
@@ -54,7 +54,7 @@ export function makeWikiAskHandler(client) {
|
|
|
54
54
|
}
|
|
55
55
|
export const wikiLintToolConfig = {
|
|
56
56
|
title: 'Run the LLM-Wiki lint pass on a space',
|
|
57
|
-
description: '
|
|
57
|
+
description: 'Lint an LLM-Wiki space for inconsistencies. It finds orphan pages, missing cross-references, stale pages, unprocessed sources and, when AI is configured, contradictions and undocumented concepts. Returns the open issues, each with a suggested fix. Wraps POST /spaces/:id/llm-wiki/lint.',
|
|
58
58
|
inputSchema: z.object({
|
|
59
59
|
spaceId: z.string().uuid().describe('The LLM-Wiki space to lint.'),
|
|
60
60
|
}).shape,
|
|
@@ -91,7 +91,7 @@ export function makeWikiPlanUpdateHandler(client) {
|
|
|
91
91
|
}
|
|
92
92
|
export const wikiApplyPlanToolConfig = {
|
|
93
93
|
title: 'Apply a previously-planned wiki edit',
|
|
94
|
-
description: 'Commit the page operations from a plan created by orboto_wiki_plan_update. Applies every op atomically (each page edit is snapshotted for rollback). Fails with 410 if the plan has expired (>15 min) or was already applied. Wraps POST /spaces/:id/docs/apply-plan.',
|
|
94
|
+
description: 'Commit the page operations from a plan created by orboto_wiki_plan_update. Applies every op atomically (each page edit is snapshotted for rollback), then rebuilds the index doc of the space and appends a line to its log doc. Fails with 410 if the plan has expired (>15 min) or was already applied. Wraps POST /spaces/:id/docs/apply-plan.',
|
|
95
95
|
inputSchema: z.object({
|
|
96
96
|
spaceId: z.string().uuid(),
|
|
97
97
|
planId: z.string().uuid().describe('The planId returned by orboto_wiki_plan_update.'),
|
|
@@ -106,7 +106,7 @@ export function makeWikiApplyPlanHandler(client) {
|
|
|
106
106
|
}
|
|
107
107
|
export const wikiRecordToolConfig = {
|
|
108
108
|
title: 'Record a wiki update in one step (plan + apply)',
|
|
109
|
-
description: '
|
|
109
|
+
description: 'Plan and apply a wiki edit in one step. Use it mid-task to capture a fact or update a page without the two-step review loop. Internally calls plan-update then apply-plan. For a reviewable change, use orboto_wiki_plan_update instead.',
|
|
110
110
|
inputSchema: z.object({
|
|
111
111
|
spaceId: z.string().uuid(),
|
|
112
112
|
instruction: z.string().min(1).max(4000).describe('What to record, in plain language.'),
|
|
@@ -175,3 +175,28 @@ export function makeWikiFlagStaleHandler(client) {
|
|
|
175
175
|
return text(res.staleFlagged ? 'Page flagged as possibly outdated.' : 'Stale flag cleared.', { staleFlagged: res.staleFlagged });
|
|
176
176
|
};
|
|
177
177
|
}
|
|
178
|
+
export const wikiRunsToolConfig = {
|
|
179
|
+
title: 'Wiki ingest runs',
|
|
180
|
+
description: 'List the ingest runs of a wiki space (status failed shows each error) or retry a failed run.',
|
|
181
|
+
inputSchema: z.object({
|
|
182
|
+
spaceId: z.string().min(1),
|
|
183
|
+
action: z.enum(['list', 'retry']).optional(),
|
|
184
|
+
runId: z.string().uuid().optional(),
|
|
185
|
+
status: z.enum(['pending_review', 'applied', 'failed', 'skipped']).optional(),
|
|
186
|
+
}).shape,
|
|
187
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
188
|
+
};
|
|
189
|
+
export function makeWikiRunsHandler(client) {
|
|
190
|
+
return async (input) => {
|
|
191
|
+
if (input.action === 'retry') {
|
|
192
|
+
if (!input.runId)
|
|
193
|
+
return { isError: true, content: [{ type: 'text', text: 'action retry needs the runId of a failed run (action list, status failed).' }] };
|
|
194
|
+
const res = await client.post(`/spaces/${encodeURIComponent(input.spaceId)}/llm-wiki/ingest-runs/${input.runId}/retry`, {});
|
|
195
|
+
return text(res.queued ? `Run ${res.runId} is queued for the ingest worker again.` : `Run ${res.runId} ran again: ${res.status}${res.error ? ` - ${res.error}` : ''}.`, res);
|
|
196
|
+
}
|
|
197
|
+
const qs = input.status ? `?status=${input.status}` : '';
|
|
198
|
+
const res = await client.get(`/spaces/${encodeURIComponent(input.spaceId)}/llm-wiki/ingest-runs${qs}`);
|
|
199
|
+
const lines = res.runs.map((r) => `- ${r.id} ${r.status} ${r.sourceKey ?? ''} "${r.sourceTitle}" ${r.createdAt}${r.error ? ` - ${r.error}` : ''}`);
|
|
200
|
+
return text(lines.length ? lines.join('\n') : 'No ingest runs.', res);
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -314,7 +314,7 @@ export function makeWorkSessionStartHandler(client) {
|
|
|
314
314
|
}
|
|
315
315
|
export const workSessionFinishToolConfig = {
|
|
316
316
|
title: 'Finish a work session (books time, frees the lease)',
|
|
317
|
-
description: 'End a work session:
|
|
317
|
+
description: 'End a work session: timer booked, lease released, evidence recorded. The timer is stopped and booked, the (ticket, role) lease is released for the next agent, and the evidence you pass (commit sha + which gates you ran) is recorded on the session. Idempotent - finishing an already-finished session succeeds and still absorbs late evidence, so a retrying harness never has to distinguish "already done" from "failed". This does NOT close the ticket; ticket status is a separate, deliberate decision (use orboto_close_ticket once the acceptance criteria are verified). Prefer orboto_work_finish (ORB-1612) for an `implementation` session that is actually done - it does this AND the ticket transition AND the completion note in one call.',
|
|
318
318
|
inputSchema: z.object({
|
|
319
319
|
sessionId: z.string().uuid().describe('The id returned by orboto_work_session_start.'),
|
|
320
320
|
outcome: z.enum(['finished', 'cancelled']).optional()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orboto/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.208.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",
|