@orboto/mcp 0.227.0 → 0.229.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/index.js +13 -2
  2. package/dist/manifest-size.test.js +3 -0
  3. package/dist/project-scope.js +15 -1
  4. package/dist/project-scope.test.js +9 -9
  5. package/dist/response-budget.js +1 -1
  6. package/dist/server.js +5 -0
  7. package/dist/session-origin.js +23 -3
  8. package/dist/tools/agent-coordination.js +13 -4
  9. package/dist/tools/agent-instructions.js +5 -4
  10. package/dist/tools/agent-who.js +12 -3
  11. package/dist/tools/bulk-writes.js +3 -3
  12. package/dist/tools/checklist-writes.js +2 -2
  13. package/dist/tools/claim.js +2 -2
  14. package/dist/tools/doc-attachments.js +4 -4
  15. package/dist/tools/doc-comments.js +5 -5
  16. package/dist/tools/doc-export.js +3 -3
  17. package/dist/tools/doc-revisions.js +3 -3
  18. package/dist/tools/docs.js +7 -7
  19. package/dist/tools/get-timer.js +6 -1
  20. package/dist/tools/identity.js +10 -1
  21. package/dist/tools/identity.test.js +11 -0
  22. package/dist/tools/knowledge.test.js +11 -0
  23. package/dist/tools/list-git-app-installations.js +1 -1
  24. package/dist/tools/phase-b-tools.test.js +7 -0
  25. package/dist/tools/presence-scope.test.js +170 -0
  26. package/dist/tools/primer-facts.js +10 -10
  27. package/dist/tools/project-readiness-schema.js +1 -1
  28. package/dist/tools/rule-proposals.js +131 -0
  29. package/dist/tools/rule-proposals.test.js +82 -0
  30. package/dist/tools/semantic-degraded-wrapper.test.js +1 -1
  31. package/dist/tools/session-start.js +52 -16
  32. package/dist/tools/session-start.test.js +7 -6
  33. package/dist/tools/shared.js +49 -0
  34. package/dist/tools/ticket-writes.js +2 -2
  35. package/dist/tools/wiki.js +14 -6
  36. package/dist/tools/work-next.js +5 -5
  37. package/dist/tools/work-sessions.js +15 -15
  38. package/package.json +1 -1
@@ -9,29 +9,31 @@
9
9
  * @see ORB-1605, ORB-1607, ORB-1818
10
10
  */
11
11
  import { z } from 'zod';
12
- import { resolveTicketByKey, applyAgentProfile, mcpInstanceToken, localSessionHost } from './shared.js';
12
+ import { resolveTicketByKey, applyAgentProfile, mcpInstanceToken, localSessionHost, presenceRefusal, exitedPredecessor } from './shared.js';
13
+ import { machineId, shortHost } from '../session-origin.js';
13
14
  import { AgentSessionScopeSchema } from './agent-session-scope.js';
14
15
  import { PROCESS_OWNER, PROTECT_TEXT_META, storePayload } from '../response-budget.js';
15
16
  import { loadRequiredRules } from '../required-rules.js';
16
17
  import { GIT_HEALTH_REASON_TEXT } from './git-health-reasons.js';
17
18
  import { channelStartLines } from '../inbox-channel.js';
18
19
  import { rememberDeclaredScope } from '../session-scope-memory.js';
19
- import { declareEnvProjectScope, envProjectScope } from '../project-scope.js';
20
+ import { declareEnvProjectScope, envProjectScope, supervisorScopePending } from '../project-scope.js';
20
21
  import { agentHeadText } from '../agent-head.js';
21
22
  import { agentContextKey } from '../agent-context.js';
22
23
  export const sessionStartToolConfig = {
23
24
  title: 'Load the rules you must follow + re-orient',
24
25
  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
+ + '**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). `previous` (adopt | end) answers a second-start refusal (ORB-2553). '
26
27
  + '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.',
27
28
  inputSchema: z.object({
28
- projectId: z.string().uuid().optional().describe('Also load this project\'s rules.'),
29
- ticketKey: z.string().min(3).optional().describe('Ticket key ("ACME-42") - bundles that ticket\'s full context.'),
30
- forceRules: z.boolean().optional().describe('Always return the full rules; set it when you do not hold them.'),
31
- rulesOnly: z.boolean().optional().describe('Return ONLY the complete rules text.'),
32
- agentKind: z.string().min(1).max(32).optional().describe('coding | orchestrator | reviewer | runner.'),
33
- modelTier: z.string().min(1).max(32).optional().describe('frontier | standard | small.'),
29
+ projectId: z.string().uuid().optional(),
30
+ ticketKey: z.string().min(3).optional(),
31
+ forceRules: z.boolean().optional(),
32
+ rulesOnly: z.boolean().optional(),
33
+ agentKind: z.string().min(1).max(32).optional(),
34
+ modelTier: z.string().min(1).max(32).optional(),
34
35
  scope: AgentSessionScopeSchema.optional(),
36
+ previous: z.enum(['adopt', 'end']).optional(),
35
37
  }).shape,
36
38
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
37
39
  };
@@ -175,6 +177,12 @@ function knowledgeSummary(index, handle) {
175
177
  };
176
178
  }
177
179
  /** ORB-2209 - a declared scope stays with the instance token; the agent declares it once per terminal. */
180
+ /** ORB-2553 - what the start did to the session it ran into. */
181
+ export function takeoverLine(t) {
182
+ if (t.action === 'ended')
183
+ return `Ended the previous session ${t.shortId} in this repository${t.releasedTickets.length ? `; its leases on ${t.releasedTickets.join(', ')} are released` : ''}.`;
184
+ return `Adopted session ${t.shortId}: ${t.movedMessages} unread message(s)${t.movedLeases.length ? ` and the leases on ${t.movedLeases.join(', ')}` : ''} moved here, it is ended.`;
185
+ }
178
186
  export function identityLine(registration) {
179
187
  const count = registration.reconnects ?? 0;
180
188
  const kept = count > 0 ? ` It survived ${count} reconnect(s), the last at ${registration.lastReconnectAt ?? 'an unknown time'}.` : '';
@@ -223,15 +231,34 @@ export function makeSessionStartHandler(client, opts = {}) {
223
231
  const toRegistration = (r) => (r && typeof r.sessionId === 'string' ? {
224
232
  sessionId: r.sessionId, scope: r.scope ?? null, reconnects: r.reconnects, lastReconnectAt: r.lastReconnectAt,
225
233
  wake: r.wake, scopeExpired: r.scopeExpired ?? null, scopeContinued: r.scopeContinued ?? null, notes: Array.isArray(r.notes) ? r.notes.filter((n) => typeof n === 'string') : [],
234
+ ...(r.takeover ? { takeover: r.takeover } : {}),
226
235
  } : null);
236
+ let refusal = null;
237
+ const refused = (err) => { refusal = presenceRefusal(err)?.message ?? refusal; return null; };
227
238
  const host = localSessionHost(extra);
228
- let registration = await client.post('/v1/agent/heartbeat', { ...(input.scope !== undefined ? { scope: input.scope } : {}), ...(host ? { clientInfo: { host } } : {}) }, { instanceToken })
229
- .then(toRegistration)
230
- .catch(() => null);
231
- if (input.scope !== undefined && registration)
239
+ const fallbackScope = input.scope === undefined && !supervisorScopePending() ? envProjectScope() : null;
240
+ const startBody = {
241
+ start: true, ...(input.previous ? { previous: input.previous } : {}), ...(input.scope !== undefined ? { scope: input.scope } : {}),
242
+ ...(fallbackScope ? { fallbackScope } : {}), ...(host ? { clientInfo: { host } } : {}),
243
+ };
244
+ const beat = (body) => client.post('/v1/agent/heartbeat', body, { instanceToken }).then(toRegistration);
245
+ let exitedLine = null;
246
+ let registration = await beat(startBody).catch(async (err) => {
247
+ const dead = exitedPredecessor(presenceRefusal(err), { machine: machineId(), host: shortHost() });
248
+ if (!dead)
249
+ return refused(err);
250
+ const again = await beat({ ...startBody, previous: 'end' }).catch(refused);
251
+ if (again)
252
+ exitedLine = dead.line;
253
+ return again;
254
+ });
255
+ if ((input.scope !== undefined || fallbackScope) && registration?.scope)
232
256
  rememberDeclaredScope(instanceToken, registration.scope);
233
- if (input.scope === undefined && registration && !registration.scope && envProjectScope()) {
234
- registration = toRegistration(await declareEnvProjectScope(client, instanceToken)) ?? registration;
257
+ if (fallbackScope && registration && !registration.scope) {
258
+ const declared = await declareEnvProjectScope(client, instanceToken);
259
+ if (declared?.refusal)
260
+ refusal = declared.refusal;
261
+ registration = toRegistration(declared) ?? (declared?.refusal ? null : registration);
235
262
  }
236
263
  const [me, rules, assigned, timer, inboxRaw, knowledge, owners, projectFacts] = await Promise.all([
237
264
  client.get('/users/me').catch(() => null),
@@ -340,7 +367,14 @@ export function makeSessionStartHandler(client, opts = {}) {
340
367
  : registration
341
368
  ? 'no scope declared - this session lists every account-addressed message; pass scope: { projectKeys, role } to narrow it'
342
369
  : 'registration failed - only account mail is listed; retry orboto_session_start or check mcp:use';
343
- lines.push(`ref ${instanceToken}${registration ? `, instance ${registration.sessionId.slice(0, 8)}` : ''}. ${scopeText}. Peers reach exactly this session with orboto_agent_notify { toSessionRef: "${registration ? registration.sessionId.slice(0, 8) : instanceToken}" }.`);
370
+ if (refusal)
371
+ lines.push(`ref ${instanceToken}. Not registered: ${refusal}`, 'With MCP: pass scope: { role, projectKeys } and, for another live session in this repository, previous: "adopt" or "end".');
372
+ else
373
+ lines.push(`ref ${instanceToken}${registration ? `, instance ${registration.sessionId.slice(0, 8)}` : ''}. ${scopeText}. Peers reach exactly this session with orboto_agent_notify { toSessionRef: "${registration ? registration.sessionId.slice(0, 8) : instanceToken}" }.`);
374
+ if (exitedLine)
375
+ lines.push(exitedLine);
376
+ else if (registration?.takeover)
377
+ lines.push(takeoverLine(registration.takeover));
344
378
  const roleAddresses = registration?.scope?.role ? (registration.scope.projectKeys ?? []).map((k) => `${registration.scope.role}@${k}`) : [];
345
379
  if (roleAddresses.length)
346
380
  lines.push(`Address form: peers reach this session and every other ${registration.scope.role} of the project with orboto_agent_notify { to: "${roleAddresses[0]}" } (${roleAddresses.join(', ')}); address a role, never a session id copied from a log.`);
@@ -384,6 +418,8 @@ export function makeSessionStartHandler(client, opts = {}) {
384
418
  reconnects: registration?.reconnects ?? 0, reconnectedAt: registration?.lastReconnectAt ?? null,
385
419
  ...(registration?.wake ? { wake: registration.wake } : {}), scopeExpired: registration?.scopeExpired ?? null,
386
420
  scopeContinued: registration?.scopeContinued ?? null,
421
+ ...(refusal ? { refused: refusal } : {}),
422
+ ...(registration?.takeover ? { takeover: registration.takeover } : {}),
387
423
  },
388
424
  gitHealth: {
389
425
  unhealthy: gitHealthWithConnections
@@ -18,8 +18,9 @@ import { makeResponseExpandHandler } from './response-expand.js';
18
18
  import { applyResponseBudget, budgetFor, DEFAULT_BUDGET_CHARS } from '../response-budget.js';
19
19
  import { hostname, tmpdir } from 'node:os';
20
20
  import { localSessionHost } from './shared.js';
21
- beforeEach(() => { vi.restoreAllMocks(); });
22
- afterEach(() => { vi.restoreAllMocks(); });
21
+ /** An `orboto claude` or `orboto connect` session exports its checkout project and role; the digest must not inherit them here. */
22
+ beforeEach(() => { vi.restoreAllMocks(); vi.stubEnv('ORBOTO_PROJECT_KEY', ''); vi.stubEnv('ORBOTO_SESSION_ROLE', ''); });
23
+ afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); });
23
24
  function stubByPath(map) {
24
25
  const calls = [];
25
26
  vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
@@ -646,7 +647,7 @@ describe('ORB-2136 - session scope and ref', () => {
646
647
  const res = await makeSessionStartHandler(client)({ scope: { role: 'integrator', projectKeys: ['orb'] } }, { sessionId: 'abc123' });
647
648
  const beat = seen.find((c) => c.path === '/v1/agent/heartbeat');
648
649
  expect(beat?.headers['x-orboto-agent-session']).toBe('mcp-abc123');
649
- expect(beat?.body).toEqual({ scope: { role: 'integrator', projectKeys: ['orb'] } });
650
+ expect(beat?.body).toEqual({ start: true, scope: { role: 'integrator', projectKeys: ['orb'] } });
650
651
  expect(seen.find((c) => c.path.startsWith('/v1/agent/messages'))?.headers['x-orboto-agent-session']).toBe('mcp-abc123');
651
652
  const text = res.content[0].text;
652
653
  expect(text).toContain('ref mcp-abc123, instance cbb52195. role integrator; projects ORB');
@@ -682,11 +683,11 @@ describe('ORB-2136 - session scope and ref', () => {
682
683
  return { ok: true, status: 200, statusText: 'OK', json: async () => body, text: async () => '' };
683
684
  });
684
685
  await makeSessionStartHandler(client)({});
685
- expect(seen.find((c) => c.path === '/v1/agent/heartbeat')?.body).toEqual({ clientInfo: { host: hostname().trim().toLowerCase() } });
686
+ expect(seen.find((c) => c.path === '/v1/agent/heartbeat')?.body).toEqual({ start: true, clientInfo: { host: hostname().trim().toLowerCase() } });
686
687
  expect(localSessionHost({ sessionId: 'http-1' }, 'server')).toBeUndefined();
687
688
  expect(localSessionHost(undefined, ' Mac-A ')).toBe('mac-a');
688
689
  });
689
- it('without a scope the heartbeat body stays empty and the digest says no scope is declared', async () => {
690
+ it('without a scope the heartbeat body carries only the start and the digest says no scope is declared', async () => {
690
691
  const seen = [];
691
692
  vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
692
693
  const u = new URL(url.toString());
@@ -697,7 +698,7 @@ describe('ORB-2136 - session scope and ref', () => {
697
698
  return { ok: true, status: 200, statusText: 'OK', json: async () => body, text: async () => '' };
698
699
  });
699
700
  const res = await makeSessionStartHandler(client)({}, { sessionId: 'abc123' });
700
- expect(seen.find((c) => c.path === '/v1/agent/heartbeat')?.body).toEqual({});
701
+ expect(seen.find((c) => c.path === '/v1/agent/heartbeat')?.body).toEqual({ start: true });
701
702
  expect(res.content[0].text).toContain('no scope declared');
702
703
  });
703
704
  it('ORB-2148: a stdio proxy with the channel on hands over the exact start commands; other servers stay silent', async () => {
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from 'node:fs';
1
2
  import { OrbotoApiError } from '../orboto-client.js';
2
3
  /** ORB-2543 - the line every search-shaped tool prints when the API flags `semanticDegraded`. */
3
4
  export const SEMANTIC_DEGRADED_LINE = 'semantic search unavailable, showing full-text results';
@@ -178,6 +179,54 @@ export function applyAgentProfile(params, explicit) {
178
179
  if (tier)
179
180
  params.set('modelTier', tier);
180
181
  }
182
+ export function presenceRefusal(err) {
183
+ if (!(err instanceof OrbotoApiError) || (err.status !== 409 && err.status !== 422))
184
+ return null;
185
+ let parsed = {};
186
+ try {
187
+ parsed = JSON.parse(err.body);
188
+ }
189
+ catch {
190
+ return null;
191
+ }
192
+ if (parsed.errorKey !== 'errors.agents.scope_required' && parsed.errorKey !== 'errors.agents.session_exists')
193
+ return null;
194
+ const notes = Array.isArray(parsed.errorParams?.notes) ? parsed.errorParams.notes.filter((n) => typeof n === 'string') : [];
195
+ return { key: parsed.errorKey, message: [parsed.error ?? parsed.errorKey, ...notes].join('\n'), params: parsed.errorParams ?? {} };
196
+ }
197
+ /** True while pid belongs to a running process: signal 0 answers ESRCH for none, and an exited zombie on Linux counts as gone. */
198
+ export function processAlive(pid) {
199
+ try {
200
+ process.kill(pid, 0);
201
+ }
202
+ catch (err) {
203
+ if (err.code === 'ESRCH')
204
+ return false;
205
+ }
206
+ try {
207
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
208
+ return stat[stat.lastIndexOf(')') + 2] !== 'Z';
209
+ }
210
+ catch {
211
+ return true;
212
+ }
213
+ }
214
+ /** Every identifier both sides name must match, machine id and host alike, so containers of one image or cloned VMs never count as one machine. */
215
+ export function sameMachine(theirMachine, ourMachine, theirHost, ourHost) {
216
+ const machines = Boolean(theirMachine && ourMachine);
217
+ const hosts = Boolean(theirHost && ourHost);
218
+ return (machines || hosts) && (!machines || theirMachine === ourMachine) && (!hosts || theirHost === ourHost);
219
+ }
220
+ /** ORB-2553 - the session a second-start refusal names when it runs on this machine under a pid that no longer runs; null otherwise. */
221
+ export function exitedPredecessor(refusal, local, alive = processAlive) {
222
+ if (refusal?.key !== 'errors.agents.session_exists')
223
+ return null;
224
+ const p = refusal.params;
225
+ const same = sameMachine(typeof p.machineId === 'string' ? p.machineId : null, local.machine, typeof p.host === 'string' ? p.host : null, local.host);
226
+ if (typeof p.pid !== 'number' || !Number.isInteger(p.pid) || p.pid <= 1 || !same || typeof p.shortId !== 'string' || alive(p.pid))
227
+ return null;
228
+ return { shortId: p.shortId, pid: p.pid, line: `previous session ${p.shortId} (pid ${p.pid}) has exited, ended it` };
229
+ }
181
230
  export function sizeBlockResult(err, verb) {
182
231
  if (!(err instanceof OrbotoApiError) || err.status !== 422)
183
232
  return null;
@@ -620,7 +620,7 @@ export const setMilestoneToolConfig = {
620
620
  description: 'Move a ticket onto a different milestone (or off all milestones with milestone=null/undefined). Resolves the milestone by key (e.g. "ORB-M3"), name, OR UUID within the ticket\'s project (including closed/archived). If two milestones share a name, pass the key or UUID - an ambiguous name is rejected, not silently guessed.',
621
621
  inputSchema: z.object({
622
622
  ticketKey: z.string().min(3),
623
- milestone: z.string().nullable().optional().describe('Milestone key (e.g. "ORB-M3"), name, or UUID. Pass the key/UUID when the name is ambiguous. Pass null to remove from any milestone.'),
623
+ milestone: z.string().nullable().optional().describe('Milestone key ("ORB-M3"), name or UUID (key when the name is ambiguous); null removes it.'),
624
624
  }).shape,
625
625
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
626
626
  };
@@ -719,7 +719,7 @@ export function makeRemoveTicketDependencyHandler(client) {
719
719
  }
720
720
  export const listTicketDependenciesToolConfig = {
721
721
  title: 'List a ticket\'s dependencies',
722
- description: 'Show both directions of a ticket\'s dependency graph: `blockedBy` (must finish first) and `blocks` (waiting on this one). ORB-1614: an edge to a ticket you cannot read comes back as an opaque stub (no title/key/status - just whether it is still open); `external` marks another project.',
722
+ description: 'Both directions of a ticket\'s dependencies: `blockedBy` (finish first) and `blocks` (waiting on it). ORB-1614: an edge to a ticket you cannot read comes back as an opaque stub (no title/key/status - just whether it is still open); `external` marks another project.',
723
723
  inputSchema: z.object({
724
724
  ticketKey: z.string().min(3),
725
725
  }).shape,
@@ -161,7 +161,7 @@ export const wikiSaveAnswerToolConfig = {
161
161
  answer: z.string().min(1).max(50_000),
162
162
  title: z.string().max(200).optional().describe('Defaults to the question.'),
163
163
  parentDocId: z.string().uuid().optional(),
164
- citations: z.array(z.object({ docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'), title: z.string() })).max(50).optional().describe('From orboto_wiki_ask.'),
164
+ citations: z.array(z.object({ docId: z.string().min(1).describe('Doc UUID or key (ORB-D12, DOC-5).'), title: z.string() })).max(50).optional().describe('From orboto_wiki_ask.'),
165
165
  replace: z.boolean().optional(),
166
166
  }).shape,
167
167
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
@@ -177,7 +177,7 @@ export const wikiFlagStaleToolConfig = {
177
177
  title: 'Flag (or unflag) a wiki page as possibly outdated',
178
178
  description: 'Set or clear the "may be outdated" flag on a page. The flag surfaces passively as a pill in the UI; it does not change the content. Pass stale=false to clear. Wraps POST /docs/:id/flag-stale.',
179
179
  inputSchema: z.object({
180
- docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
180
+ docId: z.string().min(1).describe('Doc UUID or key (ORB-D12, DOC-5).'),
181
181
  stale: z.boolean().optional().describe('true to flag (default), false to clear.'),
182
182
  }).shape,
183
183
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
@@ -189,14 +189,22 @@ export function makeWikiFlagStaleHandler(client) {
189
189
  return text(res.staleFlagged ? 'Page flagged as possibly outdated.' : 'Stale flag cleared.', { staleFlagged: res.staleFlagged });
190
190
  };
191
191
  }
192
+ /** ORB-2611 - the wait or run time of a queued run, and its stall. */
193
+ function queuedNote(r) {
194
+ if (r.status !== 'queued')
195
+ return '';
196
+ const waited = r.startedAt ? (Date.parse(r.startedAt) - Date.parse(r.queuedAt ?? r.createdAt)) / 1000 : 0;
197
+ const minutes = Math.max(1, Math.round(Math.max(0, (r.ageSeconds ?? 0) - waited) / 60));
198
+ return ` (${r.startedAt ? 'running' : 'waiting'} ${minutes} min${r.stalled ? ', stalled' : ''})`;
199
+ }
192
200
  export const wikiRunsToolConfig = {
193
201
  title: 'Wiki ingest runs',
194
- description: 'List the ingest runs of a wiki space (status failed shows each error) or retry a failed run.',
202
+ description: 'List the ingest runs of a wiki space (queued shows the wait and a stall, failed each error) or retry a failed or stalled run.',
195
203
  inputSchema: z.object({
196
204
  spaceId: z.string().min(1),
197
205
  action: z.enum(['list', 'retry']).optional(),
198
206
  runId: z.string().uuid().optional(),
199
- status: z.enum(['pending_review', 'applied', 'failed', 'skipped']).optional(),
207
+ status: z.enum(['pending_review', 'applied', 'failed', 'skipped', 'queued']).optional(),
200
208
  }).shape,
201
209
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
202
210
  };
@@ -204,13 +212,13 @@ export function makeWikiRunsHandler(client) {
204
212
  return async (input) => {
205
213
  if (input.action === 'retry') {
206
214
  if (!input.runId)
207
- return { isError: true, content: [{ type: 'text', text: 'action retry needs the runId of a failed run (action list, status failed).' }] };
215
+ return { isError: true, content: [{ type: 'text', text: 'action retry needs the runId of a failed or stalled run (action list, status failed or queued).' }] };
208
216
  const res = await client.post(`/spaces/${encodeURIComponent(input.spaceId)}/llm-wiki/ingest-runs/${input.runId}/retry`, {});
209
217
  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);
210
218
  }
211
219
  const qs = input.status ? `?status=${input.status}` : '';
212
220
  const res = await client.get(`/spaces/${encodeURIComponent(input.spaceId)}/llm-wiki/ingest-runs${qs}`);
213
- const lines = res.runs.map((r) => `- ${r.id} ${r.status} ${r.sourceKey ?? ''} "${r.sourceTitle}" ${r.createdAt}${r.droppedOps?.length ? ` (ops dropped: ${r.droppedOps.length})` : ''}${r.error ? ` - ${r.error}` : ''}`);
221
+ const lines = res.runs.map((r) => `- ${r.id} ${r.status}${queuedNote(r)} ${r.sourceKey ?? ''} "${r.sourceTitle}" ${r.createdAt}${r.droppedOps?.length ? ` (ops dropped: ${r.droppedOps.length})` : ''}${r.error ? ` - ${r.error}` : ''}`);
214
222
  return text(lines.length ? lines.join('\n') : 'No ingest runs.', res);
215
223
  };
216
224
  }
@@ -27,21 +27,21 @@ export const workNextToolConfig = {
27
27
  inputSchema: z.object({
28
28
  projectKey: z.string().min(1).describe('Project key (e.g. "ACME") or UUID.'),
29
29
  agentTag: z.string().min(1).max(64).optional()
30
- .describe('ORB-1772 - preferred routing tag: `agent:<tag>` tickets rank first, foreign `agent:*` last but eligible. Lowercased; often the model or fleet lane name.'),
30
+ .describe('Routing tag: `agent:<tag>` tickets first, foreign `agent:*` last but eligible.'),
31
31
  role: z.enum(['implementation', 'review', 'preflight', 'integration', 'spec']).optional()
32
32
  .describe('Default `implementation`; reserves only a ticket whose (ticket, role) lease is free.'),
33
33
  leaseSeconds: z.number().int().min(60).max(86_400).optional()
34
- .describe('How long the lease should hold without renewal. Default 900 (15 min).'),
34
+ .describe('Lease without renewal, default 900 (15 min).'),
35
35
  startTimer: z.boolean().optional()
36
- .describe('Defaults to true for `implementation`, false for the attach-only roles.'),
36
+ .describe('Default true for implementation, false otherwise.'),
37
37
  agentSessionToken: z.string().optional()
38
- .describe('Stable per-agent-instance token. Omit to use this MCP connection\'s own instance id.'),
38
+ .describe('Agent instance token; default this connection\'s.'),
39
39
  resourceClaims: z.array(z.object(ResourceClaimShape)).max(50).optional()
40
40
  .describe('ORB-1610 - claims to acquire with the reservation; a ticket under a conflicting granted write claim is skipped.'),
41
41
  onConflict: z.enum(['reject', 'queue']).optional()
42
42
  .describe('Only matters when `resourceClaims` is set on the WINNING candidate. Default `reject`.'),
43
43
  peek: z.boolean().optional()
44
- .describe('ORB-1930 - "is there work for me?" without reserving: the same candidate walk, no lease, timer or lane quota; returns `candidate` or the empty reason.'),
44
+ .describe('Look without reserving (no lease, timer or quota); returns `candidate` or the empty reason.'),
45
45
  }).shape,
46
46
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
47
47
  };
@@ -49,19 +49,19 @@ export const workStartToolConfig = {
49
49
  inputSchema: z.object({
50
50
  ticketKey: z.string().min(3).describe('Ticket key like "ACME-42".'),
51
51
  role: z.enum(['implementation', 'review', 'preflight', 'integration', 'spec']).optional()
52
- .describe('Default implementation; other roles attach without reassignment. Spec prepares build orders; bots need the spec tag.'),
52
+ .describe('Default implementation; others attach without reassignment; spec needs the bot spec tag.'),
53
53
  leaseSeconds: z.number().int().min(60).max(86_400).optional()
54
- .describe('How long the lease should hold without renewal. Default 900 (15 min).'),
54
+ .describe('Lease without renewal, default 900 (15 min).'),
55
55
  override: z.boolean().optional().describe("Bypass the spec gate; reason required. Never epics."),
56
56
  reason: z.string().trim().min(1).max(2000).optional(),
57
57
  takeover: z.boolean().optional()
58
- .describe('Displace the current holder of this (ticket, role) lease. Their session is cancelled and their tracked time booked.'),
58
+ .describe('Displace the lease holder; their session is cancelled, their time booked.'),
59
59
  startTimer: z.boolean().optional()
60
- .describe('Defaults to true for `implementation`, false for the attach-only roles.'),
60
+ .describe('Default true for implementation, false otherwise.'),
61
61
  agentSessionToken: z.string().optional()
62
- .describe('Stable per-agent-instance token. Omit to use this MCP connection\'s own instance id.'),
62
+ .describe('Agent instance token; default this connection\'s.'),
63
63
  resourceClaims: z.array(z.object(ResourceClaimShape)).max(50).optional()
64
- .describe('ORB-1610 - resource claims to acquire alongside the lease. Same semantics as orboto_work_session_start\'s resourceClaims.'),
64
+ .describe('Resource claims with the lease, as in orboto_work_session_start.'),
65
65
  onConflict: z.enum(['reject', 'queue']).optional()
66
66
  .describe('Only matters when `resourceClaims` is set. Default `reject`.'),
67
67
  }).shape,
@@ -216,19 +216,19 @@ export const workSessionStartToolConfig = {
216
216
  inputSchema: z.object({
217
217
  ticketKey: z.string().min(3).describe('Ticket key like "ACME-42".'),
218
218
  role: z.enum(['implementation', 'review', 'preflight', 'integration', 'spec']).optional()
219
- .describe('Default implementation; other roles attach without reassignment. Spec prepares build orders; bots need the spec tag.'),
219
+ .describe('Default implementation; others attach without reassignment; spec needs the bot spec tag.'),
220
220
  leaseSeconds: z.number().int().min(60).max(86_400).optional()
221
- .describe('How long the lease should hold without renewal. Default 900 (15 min).'),
221
+ .describe('Lease without renewal, default 900 (15 min).'),
222
222
  override: z.boolean().optional().describe("Bypass the spec gate; reason required. Never epics."),
223
223
  reason: z.string().trim().min(1).max(2000).optional(),
224
224
  takeover: z.boolean().optional()
225
- .describe('Displace the current holder of this (ticket, role) lease. Their session is cancelled and their tracked time booked.'),
225
+ .describe('Displace the lease holder; their session is cancelled, their time booked.'),
226
226
  startTimer: z.boolean().optional()
227
- .describe('Defaults to true for `implementation`, false for the attach-only roles.'),
227
+ .describe('Default true for implementation, false otherwise.'),
228
228
  agentSessionToken: z.string().optional()
229
- .describe('Stable per-agent-instance token. Omit to use this MCP connection\'s own instance id.'),
229
+ .describe('Agent instance token; default this connection\'s.'),
230
230
  resourceClaims: z.array(z.object(ResourceClaimShape)).max(50).optional()
231
- .describe('Atomic resource claims: path values are repo-relative globs, named values are exact ids. Overlapping writes conflict across the workspace; reads never conflict.'),
231
+ .describe('Atomic claims: path = repo-relative glob, named = exact id; overlapping writes conflict, reads never.'),
232
232
  onConflict: z.enum(['reject', 'queue']).optional()
233
233
  .describe('reject rolls back a new session on claim conflict; queue keeps it with waiting claims, promoted when conflicts clear.'),
234
234
  }).shape,
@@ -318,7 +318,7 @@ export const workSessionFinishToolConfig = {
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()
321
- .describe('`finished` (default) = the work completed. `cancelled` = abandoned attempt; time is still booked, history stays honest.'),
321
+ .describe('`finished` (default) or `cancelled` (abandoned; time is still booked).'),
322
322
  commitSha: z.string().optional().describe('The commit this session produced, when it produced one.'),
323
323
  verification: z.object({
324
324
  reviewOfSessionId: z.string().uuid().optional().describe('Exact independent implementation work session this review examined.'),
@@ -460,7 +460,7 @@ export const workSessionClaimsAddToolConfig = {
460
460
  inputSchema: z.object({
461
461
  sessionId: z.string().uuid().describe('The id returned by orboto_work_session_start.'),
462
462
  claims: z.array(z.object(ResourceClaimShape)).min(1).max(50)
463
- .describe('Claims to add. `kind: "path"` = glob relative to the repo root. `kind: "named"` = exact-match exclusive resource id.'),
463
+ .describe('Claims to add: path = repo-relative glob, named = exclusive exact id.'),
464
464
  onConflict: z.enum(['reject', 'queue']).optional().describe('Default `reject`.'),
465
465
  }).shape,
466
466
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
@@ -501,7 +501,7 @@ export function makeWorkSessionClaimsAddHandler(client) {
501
501
  }
502
502
  export const workSessionClaimsReleaseToolConfig = {
503
503
  title: 'Release resource claims from a live work session',
504
- description: 'ORB-1610 - drop resource claims (or all) from a session you hold, WITHOUT finishing it or touching the ticket lease/timer. Releasing a granted write claim immediately runs a grant pass, so the earliest queued waiter for that resource is promoted as part of this call - useful once you know you are done touching a subtree but are not done with the ticket. Omit `claims` to release everything the session holds.',
504
+ description: 'Drop resource claims (or all) from your session without finishing it or touching lease and timer. Releasing a granted write claim immediately runs a grant pass, so the earliest queued waiter for that resource is promoted as part of this call - useful once you know you are done touching a subtree but are not done with the ticket. Omit `claims` to release everything the session holds.',
505
505
  inputSchema: z.object({
506
506
  sessionId: z.string().uuid().describe('The id returned by orboto_work_session_start.'),
507
507
  claims: z.array(z.object({ kind: z.enum(['path', 'named']), value: z.string().min(1).max(500) })).optional()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orboto/mcp",
3
- "version": "0.227.0",
3
+ "version": "0.229.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",