@orboto/mcp 0.222.0 → 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.
@@ -8,6 +8,7 @@
8
8
  * download mechanism. `OrbotoClient.postBinary` / `getBinary` fetch the bytes.
9
9
  */
10
10
  import { z } from 'zod';
11
+ import { OrbotoApiError } from '../orboto-client.js';
11
12
  /**
12
13
  * ORB-1504 - an operator passphrase is deliberately NOT an MCP input: it
13
14
  * would land in the conversation transcript and the tool-call audit. The
@@ -16,15 +17,38 @@ import { z } from 'zod';
16
17
  */
17
18
  const seal = (run) => run.sealed ? ` [encrypted, ${run.sealCustody ?? 'platform'} key${run.sealKeyFingerprint ? ` ${run.sealKeyFingerprint}` : ''}]` : '';
18
19
  const mb = (n) => `${Math.round((n / 1024 / 1024) * 10) / 10} MB`;
20
+ /** ORB-2562 - the running run's id when the API refused a full export start with 409 errors.backups.full_export_in_progress. */
21
+ export function runningFullExportId(err) {
22
+ if (!(err instanceof OrbotoApiError) || err.status !== 409)
23
+ return null;
24
+ try {
25
+ const body = JSON.parse(err.body);
26
+ if (body.errorKey !== 'errors.backups.full_export_in_progress')
27
+ return null;
28
+ return typeof body.errorParams?.runId === 'string' ? body.errorParams.runId : null;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ /** Rethrows a full-export refusal as a sentence that names the running run. */
35
+ export function rethrowFullExportInProgress(err) {
36
+ const runId = runningFullExportId(err);
37
+ if (runId) {
38
+ throw new Error(`A full export is already running (run ${runId}); nothing was started. Download that run with orboto_download_backup once orboto_list_backups shows it as success.`);
39
+ }
40
+ throw err;
41
+ }
19
42
  export const createFullBackupToolConfig = {
20
43
  title: 'Create + download a full workspace backup',
21
- description: 'Create an on-demand FULL workspace backup (database + storage) and return it as a base64 resource attachment - one call; a background run this tool polls, minutes on a large workspace. Rows and file references share one snapshot; a missing file fails the run, never a partial archive. With backup encryption on the attachment is the sealed archive, not a ZIP. Requires admin:backup:export; scheduled jobs use orboto_trigger_backup.',
44
+ description: 'Create an on-demand FULL workspace backup (database + storage) and return it as a base64 resource attachment - one call; a background run this tool polls, minutes on a large workspace. Rows and file references share one snapshot; a missing file fails the run, never a partial archive. With backup encryption on the attachment is the sealed archive, not a ZIP. While another full export runs it starts nothing and names that run. Requires admin:backup:export; scheduled jobs use orboto_trigger_backup.',
22
45
  inputSchema: z.object({}).shape,
23
46
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
24
47
  };
25
48
  export function makeCreateFullBackupHandler(client) {
26
49
  return async () => {
27
- const { runId, sealed } = await client.post('/admin/backup/full', {});
50
+ const { runId, sealed } = await client.post('/admin/backup/full', {})
51
+ .catch(rethrowFullExportInProgress);
28
52
  const t0 = Date.now();
29
53
  for (;;) {
30
54
  await new Promise((r) => setTimeout(r, 3000));
@@ -67,6 +67,16 @@ describe('orboto_create_full_backup (ORB-1301 / async since ORB-1717)', () => {
67
67
  vi.useRealTimers();
68
68
  }
69
69
  });
70
+ it('names the running run instead of starting a second full export (ORB-2562)', async () => {
71
+ const refusal = { error: 'busy', errorKey: 'errors.backups.full_export_in_progress', errorParams: { runId: '6f0c1a52-3d4e-4b8a-9c71-2e5f8a9b0c1d' } };
72
+ const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
73
+ ok: false, status: 409, statusText: 'Conflict', headers: new Headers({ 'content-type': 'application/json' }),
74
+ json: async () => refusal, text: async () => JSON.stringify(refusal),
75
+ });
76
+ await expect(makeCreateFullBackupHandler(client)())
77
+ .rejects.toThrow('A full export is already running (run 6f0c1a52-3d4e-4b8a-9c71-2e5f8a9b0c1d); nothing was started.');
78
+ expect(fetch).toHaveBeenCalledTimes(1);
79
+ });
70
80
  it.each(['disk full', 'Backup source object is missing, unreadable or incomplete'])('a failed run surfaces %s instead of a partial ZIP', async (message) => {
71
81
  vi.useFakeTimers();
72
82
  const download = vi.spyOn(client, 'getBinary');
@@ -276,7 +276,7 @@ export const createDocToolConfig = {
276
276
  inputSchema: z.object({
277
277
  spaceId: z.string().min(1).describe('Target doc space - key (e.g. ORB-S1), name, or UUID. Discover via orboto_list_doc_spaces.'),
278
278
  title: z.string().min(1).max(255),
279
- content: z.string().optional().describe('Markdown body (CommonMark + GFM). Pass an empty string or omit for a blank page. Images are inline elements; status pills use `:badge[LABEL]{tone=success}` (neutral/info/success/warning/danger/accent, unknown reads as neutral, label <= 24 chars).'),
279
+ content: z.string().optional().describe('Markdown body (CommonMark + GFM). Empty or omitted: a blank page. Images are inline elements; status pills use `:badge[LABEL]{tone=success}` (neutral/info/success/warning/danger/accent, unknown = neutral, label <= 24 chars).'),
280
280
  parentDocId: z.string().uuid().nullable().optional().describe('Nest under another doc.'),
281
281
  visibility: z.enum(['public', 'workspace', 'members', 'specific']).optional().describe('Defaults to "workspace".'),
282
282
  icon: z.string().nullish().describe('Single emoji shown in the tree.'),
@@ -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. Operator-only action; bots without project:edit cannot self-verify. Removes the (observed) marker from the rendered primer and stamps verifiedBy / verifiedAt.',
250
+ description: 'Promote an agent-observed fact to verified. A person verifies: a bot account or an agent key gets 403 errors.primer_facts.verify_needs_person, so an agent proposes and never confirms. 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
- fix: z.object({ method: z.string(), route: z.string(), mcpTool: z.string(), cliCommand: z.string(), settingsPath: z.string() }),
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(),
@@ -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 against the workspace and return matching tickets. '
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 - use `reporterType = guest OR assigneeType = guest` to pull up everything reported by or assigned to guests without listing each guest email. '
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
- + 'Reach for this tool when per-entity tools (orboto_list_tickets / orboto_my_tickets) cannot express the combined filter.',
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
- export function makeQueryHandler(client) {
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
- const page = await client.post('/query', body);
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');
@@ -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 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. '
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. It also carries the Responsibilities block (ORB-2542): who owns the topics of your declared projects, looked up with orboto_who for before you send agent mail.',
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
- let lastKnownRulesHash;
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
- if (lastKnownRulesHash && !wantsFullRules)
176
- rulesParams.set('knownRulesHash', lastKnownRulesHash);
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
- lastKnownRulesHash = rules.rulesHash;
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 rulesIndex = Array.isArray(rules.rulesIndex)
260
- ? rules.rulesIndex.map((e) => e?.title).filter((t) => typeof t === 'string' && t.length > 0)
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 - after a compaction, a /clear, or as a fresh agent.';
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 connection last delivered them (hash ${rules.rulesHash}), so they were left out to save context.`, 'If the rules are NOT in your context right now - after a compaction, a /clear, or because you are a fresh agent on an existing connection - 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.');
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}). Titles only - the full text is NOT in this response.`, `${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.)`, ...rulesIndex.map((title, i) => `${i + 1}. ${title}`));
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
- lastKnownRulesHash = rules.rulesHash;
402
+ if (context && deliverFull && rules.rulesHash)
403
+ delivered = { context, hash: rules.rulesHash };
367
404
  return result;
368
405
  };
369
406
  }