@orboto/mcp 0.178.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/LICENSE.md +21 -0
- package/README.md +123 -0
- package/dist/event-bridge.js +250 -0
- package/dist/event-bridge.test.js +165 -0
- package/dist/http-transport-binding.test.js +151 -0
- package/dist/http-transport.js +608 -0
- package/dist/http-transport.test.js +408 -0
- package/dist/index.js +128 -0
- package/dist/input-schema.js +293 -0
- package/dist/input-schema.test.js +198 -0
- package/dist/instructions-budget.test.js +34 -0
- package/dist/manifest-size.test.js +192 -0
- package/dist/mcp-instrument.js +24 -0
- package/dist/oauth-bootstrap.js +413 -0
- package/dist/oauth-bootstrap.test.js +212 -0
- package/dist/orboto-client.js +250 -0
- package/dist/orboto-client.test.js +141 -0
- package/dist/prompts.js +219 -0
- package/dist/prompts.test.js +79 -0
- package/dist/protect-text-usage.test.js +38 -0
- package/dist/resources.js +260 -0
- package/dist/resources.test.js +191 -0
- package/dist/response-budget.js +515 -0
- package/dist/response-budget.test.js +343 -0
- package/dist/server.js +547 -0
- package/dist/session-nudge.js +81 -0
- package/dist/session-nudge.test.js +100 -0
- package/dist/session-nudge.transport.test.js +100 -0
- package/dist/tool-annotations.test.js +97 -0
- package/dist/tool-docs.js +64 -0
- package/dist/tool-docs.test.js +90 -0
- package/dist/tool-parameters.test.js +145 -0
- package/dist/tools/absence-writes.js +93 -0
- package/dist/tools/absence-writes.test.js +106 -0
- package/dist/tools/admin-translations.js +83 -0
- package/dist/tools/admin-writes.js +174 -0
- package/dist/tools/admin-writes.test.js +120 -0
- package/dist/tools/agent-coordination.js +160 -0
- package/dist/tools/agent-coordination.test.js +156 -0
- package/dist/tools/agent-drift.js +79 -0
- package/dist/tools/agent-drift.test.js +87 -0
- package/dist/tools/agent-instructions.js +172 -0
- package/dist/tools/agent-instructions.test.js +124 -0
- package/dist/tools/agent-messages.js +75 -0
- package/dist/tools/ai-status.js +55 -0
- package/dist/tools/ai-status.test.js +43 -0
- package/dist/tools/ai-usage.js +68 -0
- package/dist/tools/analytics.js +72 -0
- package/dist/tools/analytics.test.js +71 -0
- package/dist/tools/api-call.js +72 -0
- package/dist/tools/api-call.test.js +104 -0
- package/dist/tools/api-search.js +77 -0
- package/dist/tools/api-search.test.js +90 -0
- package/dist/tools/approvals.js +68 -0
- package/dist/tools/approvals.test.js +48 -0
- package/dist/tools/attach.js +108 -0
- package/dist/tools/attach.test.js +117 -0
- package/dist/tools/backup.js +93 -0
- package/dist/tools/backup.test.js +108 -0
- package/dist/tools/bulk-create.js +213 -0
- package/dist/tools/bulk-create.test.js +135 -0
- package/dist/tools/bulk-writes.js +339 -0
- package/dist/tools/bulk-writes.test.js +239 -0
- package/dist/tools/check-similar.js +84 -0
- package/dist/tools/check-similar.test.js +101 -0
- package/dist/tools/checklist-writes.js +202 -0
- package/dist/tools/checklist-writes.test.js +184 -0
- package/dist/tools/claim.js +236 -0
- package/dist/tools/claim.test.js +207 -0
- package/dist/tools/critical-path.js +83 -0
- package/dist/tools/critical-path.test.js +114 -0
- package/dist/tools/cross-project-links.ee.js +124 -0
- package/dist/tools/cross-project-links.ee.test.js +163 -0
- package/dist/tools/customer-report.js +88 -0
- package/dist/tools/customer-report.test.js +55 -0
- package/dist/tools/doc-attachments.js +184 -0
- package/dist/tools/doc-attachments.test.js +136 -0
- package/dist/tools/doc-comments.js +228 -0
- package/dist/tools/doc-comments.test.js +192 -0
- package/dist/tools/doc-edits.js +229 -0
- package/dist/tools/doc-edits.test.js +218 -0
- package/dist/tools/doc-export.js +87 -0
- package/dist/tools/doc-export.test.js +109 -0
- package/dist/tools/doc-revisions.js +133 -0
- package/dist/tools/doc-revisions.test.js +103 -0
- package/dist/tools/docs-ai.js +171 -0
- package/dist/tools/docs-ai.test.js +160 -0
- package/dist/tools/docs.js +557 -0
- package/dist/tools/docs.test.js +365 -0
- package/dist/tools/embedding-status.js +100 -0
- package/dist/tools/embedding-status.test.js +86 -0
- package/dist/tools/free-busy.js +49 -0
- package/dist/tools/free-busy.test.js +46 -0
- package/dist/tools/get-checklists.js +72 -0
- package/dist/tools/get-project-primer.js +58 -0
- package/dist/tools/get-project.js +77 -0
- package/dist/tools/get-ticket.js +321 -0
- package/dist/tools/get-timer.js +62 -0
- package/dist/tools/help.js +44 -0
- package/dist/tools/identity.js +60 -0
- package/dist/tools/identity.test.js +67 -0
- package/dist/tools/list-git-app-installations.js +45 -0
- package/dist/tools/list-projects.js +72 -0
- package/dist/tools/list-projects.test.js +67 -0
- package/dist/tools/list-tickets.js +86 -0
- package/dist/tools/milestones-crud.test.js +154 -0
- package/dist/tools/milestones.js +279 -0
- package/dist/tools/milestones.test.js +60 -0
- package/dist/tools/my-tickets.js +65 -0
- package/dist/tools/personal-facts.js +72 -0
- package/dist/tools/personal-facts.test.js +45 -0
- package/dist/tools/phase-b-tools.test.js +530 -0
- package/dist/tools/portfolio.js +56 -0
- package/dist/tools/primer-facts.js +346 -0
- package/dist/tools/primer-facts.test.js +353 -0
- package/dist/tools/project-listings.js +116 -0
- package/dist/tools/project-listings.test.js +106 -0
- package/dist/tools/query.js +65 -0
- package/dist/tools/query.test.js +128 -0
- package/dist/tools/raci.js +108 -0
- package/dist/tools/raci.test.js +70 -0
- package/dist/tools/requirements-spec.js +89 -0
- package/dist/tools/requirements-spec.test.js +62 -0
- package/dist/tools/response-expand.js +104 -0
- package/dist/tools/review-policy.js +105 -0
- package/dist/tools/review-policy.test.js +71 -0
- package/dist/tools/search.js +78 -0
- package/dist/tools/session-start.js +435 -0
- package/dist/tools/session-start.test.js +568 -0
- package/dist/tools/set-parent.js +76 -0
- package/dist/tools/set-parent.test.js +115 -0
- package/dist/tools/shared.js +226 -0
- package/dist/tools/shared.test.js +169 -0
- package/dist/tools/similar-projection.js +30 -0
- package/dist/tools/ticket-attachments.js +131 -0
- package/dist/tools/ticket-attachments.test.js +106 -0
- package/dist/tools/ticket-schedules.js +112 -0
- package/dist/tools/ticket-schedules.test.js +102 -0
- package/dist/tools/ticket-writes.js +778 -0
- package/dist/tools/ticket-writes.test.js +643 -0
- package/dist/tools/time-writes.js +238 -0
- package/dist/tools/time-writes.test.js +138 -0
- package/dist/tools/update-project.js +165 -0
- package/dist/tools/update-project.test.js +172 -0
- package/dist/tools/wiki.js +185 -0
- package/dist/tools/wiki.test.js +104 -0
- package/dist/tools/work-sessions-next.test.js +150 -0
- package/dist/tools/work-sessions-start.test.js +174 -0
- package/dist/tools/work-sessions.js +709 -0
- package/dist/tools/work-sessions.test.js +358 -0
- package/dist/toolset.js +149 -0
- package/dist/toolset.test.js +150 -0
- package/dist/version-guard.test.js +51 -0
- package/dist/version.js +28 -0
- package/dist/with-metrics-mail-nudge.test.js +41 -0
- package/dist/with-metrics-output-schema.test.js +91 -0
- package/dist/with-metrics.js +211 -0
- package/dist/with-metrics.test.js +230 -0
- package/package.json +66 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1093 - `orboto_session_start`: a re-orientation digest for the
|
|
3
|
+
* start of a session AND right after a context compaction, the points
|
|
4
|
+
* where coding agents lose the thread. Composes the workspace
|
|
5
|
+
* working-rules + the caller's in-progress work + timer into one
|
|
6
|
+
* briefing so the agent re-anchors on how to work and what it was
|
|
7
|
+
* doing. Read-only.
|
|
8
|
+
*
|
|
9
|
+
* ORB-1605 - also surfaces git-connection health for the projects the
|
|
10
|
+
* caller currently has open work in. A dead/unhealthy connection means
|
|
11
|
+
* commit ingestion may be stalled, which the agent should know about
|
|
12
|
+
* BEFORE it assumes a closing check that depends on git activity is
|
|
13
|
+
* reliable.
|
|
14
|
+
*
|
|
15
|
+
* ORB-1607 - lean startup contract:
|
|
16
|
+
* (a) rules-hash ack. `/agent-instructions` now returns a stable
|
|
17
|
+
* `rulesHash`. This handler remembers the last hash it saw FOR
|
|
18
|
+
* THE LIFETIME OF THIS MCP CONNECTION (one handler closure per
|
|
19
|
+
* `buildOrbotoMcpServer` call - see server.ts) and passes it back
|
|
20
|
+
* as `knownRulesHash` on every subsequent call. An unchanged hash
|
|
21
|
+
* collapses the multi-thousand-token rules block into a one-line
|
|
22
|
+
* ack, which is most of the field-measured 12-18k token/session
|
|
23
|
+
* cost on a workspace with configured instruction blocks. Never
|
|
24
|
+
* exposed as a tool input - the caller (an LLM) just calls the
|
|
25
|
+
* tool the same way every time; the cache is transparent.
|
|
26
|
+
* (b) optional `ticketKey` input bundles a project primer, the full
|
|
27
|
+
* ticket (incl. dependencies + checklists), that project's git
|
|
28
|
+
* health, and any other agent sessions currently working the same
|
|
29
|
+
* ticket into the SAME response - replacing what would otherwise
|
|
30
|
+
* be >=4 separate tool calls (get_project_primer, get_ticket,
|
|
31
|
+
* get_checklists, list_ticket_dependencies) at the point an agent
|
|
32
|
+
* has the least context loaded.
|
|
33
|
+
*
|
|
34
|
+
* ORB-1818 - the rules INDEX. Measured on production 2026-09-03: the
|
|
35
|
+
* last 15 `session_start` calls cost 21.8k-29.3k characters each, 89 %
|
|
36
|
+
* of it the assembled rule text, and none was ever truncated (the tool
|
|
37
|
+
* held a 48k per-tool budget). A result is re-sent on every later
|
|
38
|
+
* request, so that block was the single largest carry cost an agent
|
|
39
|
+
* paid, on every cold start, whether or not it needed a single rule.
|
|
40
|
+
*
|
|
41
|
+
* The default answer now carries the rules HASH plus one line per rule
|
|
42
|
+
* block (title only, in delivery order) and a `response_expand` handle;
|
|
43
|
+
* the full, byte-identical text is one call away via `rulesOnly: true`
|
|
44
|
+
* (stateless, never truncated) or that handle (in-process, 15 min). The
|
|
45
|
+
* ack semantics are unchanged: a caller that already acked this exact
|
|
46
|
+
* hash gets neither the text NOR the index, and `forceRules: true`
|
|
47
|
+
* still returns the full text inline for offline agents and operator
|
|
48
|
+
* debugging.
|
|
49
|
+
*/
|
|
50
|
+
import { z } from 'zod';
|
|
51
|
+
import { resolveTicketByKey, applyAgentProfile } from './shared.js';
|
|
52
|
+
import { PROTECT_TEXT_META, storePayload } from '../response-budget.js';
|
|
53
|
+
export const sessionStartToolConfig = {
|
|
54
|
+
title: 'Load the rules you must follow + re-orient',
|
|
55
|
+
description: 'THE canonical way to LOAD the binding workspace rules you must follow as an agent. Run it as your FIRST action in a session and immediately AFTER any context compaction. Returns a one-line-per-rule INDEX of the binding working-rules plus their hash (or a compact "unchanged" ack on a repeat call within the same connection) - read the index, then call this tool again with `rulesOnly: true` to read the full text of the rules whenever you do not already hold that exact hash, and expand before acting on any rule whose title touches what you are about to do. Also returns your in-progress tickets - each flagged LANDED, IDLE when it has a linked commit but has not moved for days, i.e. finished work you never handed to review - your running timer, and a warning if a project\'s git connection looks unhealthy (commit ingestion may be stalled). Pass `ticketKey` to also get a one-shot bundle for that ticket: project primer, the full ticket with dependencies + checklists, that project\'s git health, and any other agent sessions currently on it - replacing several separate calls. (Do NOT use orboto_list_agent_instructions to read the rules - that tool MANAGES/edits rule blocks for admins; this one is what you read to know how to work.) Read-only; no side effects. '
|
|
56
|
+
// ORB-1805 - parameter prose moved out of the input schema (paid for
|
|
57
|
+
// at every connect) into the description orboto_help serves in full.
|
|
58
|
+
+ '**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.',
|
|
59
|
+
inputSchema: z.object({
|
|
60
|
+
projectId: z.string().uuid().optional().describe('Also load this project\'s rules.'),
|
|
61
|
+
ticketKey: z.string().min(3).optional().describe('Ticket key ("ACME-42") - bundles that ticket\'s full context.'),
|
|
62
|
+
// ORB-1697 - the caller is the only party that knows whether it still
|
|
63
|
+
// HOLDS the rules. See the ack-defect note on the handler below.
|
|
64
|
+
forceRules: z.boolean().optional().describe('Always return the full rules; set it when you do not hold them.'),
|
|
65
|
+
// ORB-1818 - the stateless way back from the index to the full text.
|
|
66
|
+
rulesOnly: z.boolean().optional().describe('Return ONLY the complete rules text.'),
|
|
67
|
+
// ORB-1753 - self-declared classification for rule targeting; defaults
|
|
68
|
+
// to ORBOTO_AGENT_KIND / ORBOTO_MODEL_TIER env, then the api-key's
|
|
69
|
+
// standing profile server-side.
|
|
70
|
+
agentKind: z.string().min(1).max(32).optional().describe('coding | orchestrator | reviewer | runner.'),
|
|
71
|
+
modelTier: z.string().min(1).max(32).optional().describe('frontier | standard | small.'),
|
|
72
|
+
}).shape,
|
|
73
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
74
|
+
};
|
|
75
|
+
const GIT_HEALTH_REASON_TEXT = {
|
|
76
|
+
connection_inactive: 'connection is deactivated',
|
|
77
|
+
app_installation_suspended: 'GitHub App installation is suspended',
|
|
78
|
+
oauth_token_expired: 'OAuth token expired with no refresh path',
|
|
79
|
+
history_backfill_error: 'last history backfill failed',
|
|
80
|
+
// ORB-1638
|
|
81
|
+
awaiting_first_event: 'webhook installed but has never delivered an event',
|
|
82
|
+
outbound_unreachable: 'orboto cannot reach the provider',
|
|
83
|
+
// ORB-1785 - two derivations now: the provider reporting a failed
|
|
84
|
+
// delivery, and the probe inferring one from commits that never arrived.
|
|
85
|
+
delivery_failing: 'webhook deliveries from the provider are not arriving',
|
|
86
|
+
};
|
|
87
|
+
// Cap how many distinct projects we probe for git health - a session's
|
|
88
|
+
// in-progress work is capped at 20 tickets already, so this rarely
|
|
89
|
+
// exceeds a handful, but bound it defensively so a pathological account
|
|
90
|
+
// can't turn session-start into N parallel requests.
|
|
91
|
+
const MAX_GIT_HEALTH_PROJECTS = 8;
|
|
92
|
+
/** ORB-1607 - build the `--ticket` one-shot bundle. Never throws: an
|
|
93
|
+
* unresolvable/unauthorized ticket key comes back as a text error
|
|
94
|
+
* section instead of failing the whole digest (the rules + in-progress
|
|
95
|
+
* work above it are still useful on their own). */
|
|
96
|
+
async function buildTicketBundle(client, ticketKey) {
|
|
97
|
+
let resolved;
|
|
98
|
+
try {
|
|
99
|
+
resolved = await resolveTicketByKey(client, ticketKey);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
103
|
+
return {
|
|
104
|
+
lines: ['', `## Ticket bundle: ${ticketKey}`, `Could not load this ticket: ${message}`],
|
|
105
|
+
structured: { ticketKey, error: message },
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const [enriched, checklists, deps, primer, gitHealthConnections, activeSessions] = await Promise.all([
|
|
109
|
+
// resolveTicketByKey hits the by-key endpoint, which returns a bare
|
|
110
|
+
// row (no statusCategory / assignees / description). Re-fetch the
|
|
111
|
+
// enriched by-id shape, mirroring orboto_get_ticket.
|
|
112
|
+
client.get(`/projects/${resolved.projectId}/tickets/${resolved.id}`).catch(() => resolved),
|
|
113
|
+
client.get(`/tickets/${resolved.id}/checklists`)
|
|
114
|
+
.then((r) => (Array.isArray(r) ? r : []))
|
|
115
|
+
.catch(() => []),
|
|
116
|
+
client
|
|
117
|
+
.get(`/projects/${resolved.projectId}/tickets/${resolved.id}/dependencies`)
|
|
118
|
+
.then((r) => ({
|
|
119
|
+
blockedBy: Array.isArray(r?.blockedBy) ? r.blockedBy : [],
|
|
120
|
+
blocks: Array.isArray(r?.blocks) ? r.blocks : [],
|
|
121
|
+
}))
|
|
122
|
+
.catch(() => ({ blockedBy: [], blocks: [] })),
|
|
123
|
+
client.get(`/projects/${resolved.projectId}/ai-primer?format=json`).catch(() => null),
|
|
124
|
+
client
|
|
125
|
+
.get(`/projects/${resolved.projectId}/git-health`)
|
|
126
|
+
.then((r) => (Array.isArray(r?.connections) ? r.connections : []))
|
|
127
|
+
.catch(() => []),
|
|
128
|
+
// ORB-704 - non-admins only see their own sessions; that's an
|
|
129
|
+
// acceptable "cheaply available" degrade, not a bug to work around.
|
|
130
|
+
client.get('/v1/agent/presence').catch(() => []),
|
|
131
|
+
]);
|
|
132
|
+
// ORB-1697 - `enriched ?? resolved` trusted truthiness: a 200 whose body
|
|
133
|
+
// is `{}` is truthy, so the fallback never fired and the bundle rendered
|
|
134
|
+
// "Ticket bundle: undefined" with empty fields. Fall back on the row we
|
|
135
|
+
// already resolved unless the enriched read actually carries a ticket.
|
|
136
|
+
const full = enriched?.ticketKey ? enriched : resolved;
|
|
137
|
+
// ORB-1697 - the `.catch` above only covers a REJECTED request. A 200 with
|
|
138
|
+
// an unexpected body (a wrapped/paginated shape, or an older instance)
|
|
139
|
+
// would make `.filter` throw and take the whole digest down over an
|
|
140
|
+
// optional section. Coerce instead.
|
|
141
|
+
const sessions = Array.isArray(activeSessions) ? activeSessions : [];
|
|
142
|
+
const connections = Array.isArray(gitHealthConnections) ? gitHealthConnections : [];
|
|
143
|
+
const sessionsOnTicket = sessions.filter((s) => s.workingOnTicket?.key === full.ticketKey);
|
|
144
|
+
const unhealthy = connections.filter((c) => !c.healthy);
|
|
145
|
+
const lines = ['', `## Ticket bundle: ${full.ticketKey}`];
|
|
146
|
+
lines.push('', '### Project primer');
|
|
147
|
+
lines.push(primer?.markdown?.trim() || '(primer unavailable)');
|
|
148
|
+
lines.push('', '### Ticket');
|
|
149
|
+
lines.push(`[${full.ticketKey}] ${full.title}`);
|
|
150
|
+
lines.push(`Status: ${full.statusName ?? full.status} Priority: ${full.priority} Type: ${full.type}`);
|
|
151
|
+
if (full.description)
|
|
152
|
+
lines.push('', full.description);
|
|
153
|
+
lines.push('', '### Checklists');
|
|
154
|
+
if (checklists.length === 0)
|
|
155
|
+
lines.push('(none)');
|
|
156
|
+
else {
|
|
157
|
+
for (const cl of checklists) {
|
|
158
|
+
lines.push(`${cl.title} (${cl.progress.done}/${cl.progress.total})${cl.triggersDone ? ' · triggers done' : ''}`);
|
|
159
|
+
for (const i of cl.items) {
|
|
160
|
+
const link = i.linkedTicketKey ? ` ↪ [${i.linkedTicketKey}] (${i.linkedTicketStatusCategory ?? 'unknown'})` : '';
|
|
161
|
+
lines.push(`- [${i.effectiveCompleted ? 'x' : ' '}] ${i.content}${link}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
lines.push('', '### Dependencies');
|
|
166
|
+
// ORB-1614 - a cross-project blocker/dependent the caller cannot read
|
|
167
|
+
// comes back with `title: null` - render a fixed, non-identifying
|
|
168
|
+
// placeholder instead of the literal "null".
|
|
169
|
+
const fmtDeps = (edges) => edges.length === 0 ? '(none)' : edges.map((e) => `- [${e.ticketKey ?? '?'}] ${e.title ?? `External dependency (access restricted)${e.resolved ? ' - resolved' : ' - still open'}`}${e.statusName ? ` - ${e.statusName}` : ''}`).join('\n');
|
|
170
|
+
lines.push('Blocked by:', fmtDeps(deps.blockedBy), 'Blocks:', fmtDeps(deps.blocks));
|
|
171
|
+
if (unhealthy.length > 0) {
|
|
172
|
+
lines.push('', '### Git connection health - WARNING');
|
|
173
|
+
for (const c of unhealthy) {
|
|
174
|
+
lines.push(`- "${c.name}" (${c.provider}) - ${GIT_HEALTH_REASON_TEXT[c.reason ?? ''] ?? c.reason ?? 'unknown reason'}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
lines.push('', '### Active agent sessions on this ticket');
|
|
178
|
+
lines.push(sessionsOnTicket.length === 0
|
|
179
|
+
? '(none visible - non-admin callers only see their own sessions)'
|
|
180
|
+
: sessionsOnTicket.map((s) => `- ${s.userFullName ?? s.userEmail} - ${s.status}, last seen ${s.lastSeenAt}`).join('\n'));
|
|
181
|
+
return {
|
|
182
|
+
lines,
|
|
183
|
+
projectId: resolved.projectId,
|
|
184
|
+
structured: {
|
|
185
|
+
ticketKey: full.ticketKey,
|
|
186
|
+
title: full.title,
|
|
187
|
+
status: full.statusName ?? full.status,
|
|
188
|
+
priority: full.priority,
|
|
189
|
+
type: full.type,
|
|
190
|
+
description: full.description ?? null,
|
|
191
|
+
primer: primer ? { markdown: primer.markdown, totalTokens: primer.totalTokens, truncatedSections: primer.truncatedSections } : null,
|
|
192
|
+
checklists: checklists.map((cl) => ({
|
|
193
|
+
title: cl.title,
|
|
194
|
+
progress: cl.progress,
|
|
195
|
+
items: cl.items.map((i) => ({ content: i.content, done: i.effectiveCompleted })),
|
|
196
|
+
})),
|
|
197
|
+
dependencies: {
|
|
198
|
+
blockedBy: deps.blockedBy.map((e) => ({ ticketKey: e.ticketKey, title: e.title, statusName: e.statusName, external: e.external, resolved: e.resolved })),
|
|
199
|
+
blocks: deps.blocks.map((e) => ({ ticketKey: e.ticketKey, title: e.title, statusName: e.statusName, external: e.external, resolved: e.resolved })),
|
|
200
|
+
},
|
|
201
|
+
// ORB-1697 - only the connections that need attention. A healthy
|
|
202
|
+
// connection is 11 fields of "everything is fine" the agent never
|
|
203
|
+
// acts on; the count preserves the fact that connections exist.
|
|
204
|
+
gitHealth: { unhealthy, healthyCount: connections.length - unhealthy.length },
|
|
205
|
+
activeSessions: sessionsOnTicket.map((s) => ({ userFullName: s.userFullName, userEmail: s.userEmail, status: s.status, lastSeenAt: s.lastSeenAt })),
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
export function makeSessionStartHandler(client) {
|
|
210
|
+
// ORB-1607 - per-connection rules-hash cache. `buildOrbotoMcpServer`
|
|
211
|
+
// calls this factory once per MCP connection, so this closure variable
|
|
212
|
+
// lives for exactly that connection's lifetime and resets on reconnect.
|
|
213
|
+
//
|
|
214
|
+
// ORB-1697 - the defect that cache had: a stdio MCP server outlives
|
|
215
|
+
// `/clear` and every context compaction, so a repeat call answered
|
|
216
|
+
// "unchanged, keep following what you already loaded" at exactly the
|
|
217
|
+
// moment the agent no longer held the rules. The connection is the wrong
|
|
218
|
+
// thing to key the ack on; only the CALLER knows what is still in its
|
|
219
|
+
// context. Hence `forceRules`, and an ack text that says so.
|
|
220
|
+
let lastKnownRulesHash;
|
|
221
|
+
return async (input = {}) => {
|
|
222
|
+
const rulesParams = new URLSearchParams();
|
|
223
|
+
if (input.projectId)
|
|
224
|
+
rulesParams.set('projectId', input.projectId);
|
|
225
|
+
// ORB-1753 - rule targeting: explicit input > env > (server-side) the
|
|
226
|
+
// api-key's standing profile. The rules hash is profile-specific
|
|
227
|
+
// server-side, so the cached ack stays valid per profile.
|
|
228
|
+
applyAgentProfile(rulesParams, { agentKind: input.agentKind, modelTier: input.modelTier });
|
|
229
|
+
// ORB-1818 - both full-text answers bypass the ack: the caller is
|
|
230
|
+
// asking for the text precisely because it does not hold it.
|
|
231
|
+
const wantsFullRules = input.forceRules === true || input.rulesOnly === true;
|
|
232
|
+
if (lastKnownRulesHash && !wantsFullRules)
|
|
233
|
+
rulesParams.set('knownRulesHash', lastKnownRulesHash);
|
|
234
|
+
const rulesQs = rulesParams.toString();
|
|
235
|
+
const rulesPath = rulesQs ? `/agent-instructions?${rulesQs}` : '/agent-instructions';
|
|
236
|
+
// ORB-1818 - `rulesOnly`: the STATELESS way back from the index to the
|
|
237
|
+
// full text. The `response_expand` handle the index also carries lives
|
|
238
|
+
// in this process' memory (15 min, 16 payloads), so a server restart,
|
|
239
|
+
// a reconnect or simply 16 later truncations can retire it - and a
|
|
240
|
+
// binding rule must never become unreachable. This path re-reads the
|
|
241
|
+
// rules from the API instead, costs one call, and skips the whole rest
|
|
242
|
+
// of the digest. Protected in both halves: it IS the rule text.
|
|
243
|
+
if (input.rulesOnly) {
|
|
244
|
+
const rules = await client.get(rulesPath).catch(() => ({}));
|
|
245
|
+
if (rules.rulesHash)
|
|
246
|
+
lastKnownRulesHash = rules.rulesHash;
|
|
247
|
+
const text = rules.instructions?.trim() ?? '';
|
|
248
|
+
return {
|
|
249
|
+
_meta: { [PROTECT_TEXT_META]: true },
|
|
250
|
+
content: [{
|
|
251
|
+
type: 'text',
|
|
252
|
+
text: [
|
|
253
|
+
'# orboto working rules - complete',
|
|
254
|
+
`Hash ${rules.rulesHash ?? '(unknown)'}. Follow these; they are binding.`,
|
|
255
|
+
'',
|
|
256
|
+
text || '(no workspace rules configured)',
|
|
257
|
+
].join('\n'),
|
|
258
|
+
}],
|
|
259
|
+
structuredContent: {
|
|
260
|
+
rules: rules.instructions ?? '',
|
|
261
|
+
rulesHash: rules.rulesHash ?? null,
|
|
262
|
+
rulesUnchanged: false,
|
|
263
|
+
rulesDelivery: 'full',
|
|
264
|
+
rulesChars: (rules.instructions ?? '').length,
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const [me, rules, assigned, timer, inboxRaw] = await Promise.all([
|
|
269
|
+
client.get('/users/me').catch(() => null),
|
|
270
|
+
client.get(rulesPath).catch(() => ({})),
|
|
271
|
+
// ORB-1330 - a re-orientation briefing must only list OPEN work.
|
|
272
|
+
// Filter to in_progress + in_review so DONE tickets can't pose as
|
|
273
|
+
// "what you're working on" at the moment the agent has the least
|
|
274
|
+
// context and would otherwise re-claim / re-report finished work.
|
|
275
|
+
// Cap 20.
|
|
276
|
+
client.get('/users/me/assigned-tickets?statuses=IN_PROGRESS,IN_REVIEW&limit=20').catch(() => ({ items: [] })),
|
|
277
|
+
client.get('/time/timer').catch(() => null),
|
|
278
|
+
// ORB-1727 - unread agent messages, delivered right where an agent
|
|
279
|
+
// re-orients. Coerced defensively (ORB-1697 lesson) and optional.
|
|
280
|
+
client.get('/v1/agent/messages?limit=10')
|
|
281
|
+
.then((r) => (Array.isArray(r?.messages) ? r.messages : []))
|
|
282
|
+
.catch(() => []),
|
|
283
|
+
]);
|
|
284
|
+
const pendingMessages = inboxRaw.map((m) => ({ id: m.id, fromUserId: m.fromUserId, kind: m.kind, subject: m.subject, createdAt: m.createdAt }));
|
|
285
|
+
if (rules.rulesHash)
|
|
286
|
+
lastKnownRulesHash = rules.rulesHash;
|
|
287
|
+
const tickets = Array.isArray(assigned) ? assigned : (assigned?.items ?? []);
|
|
288
|
+
// ORB-1605 - git-connection health for every project the caller has
|
|
289
|
+
// open work in right now. Cheap, computed, read-only (see
|
|
290
|
+
// services/git-health.ts) - safe to fan out on every session start.
|
|
291
|
+
const projectIds = [...new Set(tickets.map((t) => t.projectId).filter((id) => !!id))].slice(0, MAX_GIT_HEALTH_PROJECTS);
|
|
292
|
+
const gitHealthByProject = await Promise.all(projectIds.map(async (projectId) => ({
|
|
293
|
+
projectId,
|
|
294
|
+
connections: await client
|
|
295
|
+
.get(`/projects/${projectId}/git-health`)
|
|
296
|
+
// ORB-1697 - `.catch` covers a rejected request, not a 200 whose
|
|
297
|
+
// body lacks `connections` (an older instance, or a shape change).
|
|
298
|
+
// Without the guard, an optional warning section takes the whole
|
|
299
|
+
// digest down at the moment the agent has the least context.
|
|
300
|
+
.then((r) => (Array.isArray(r?.connections) ? r.connections : []))
|
|
301
|
+
.catch(() => []),
|
|
302
|
+
})));
|
|
303
|
+
const gitHealthWithConnections = gitHealthByProject.filter((p) => p.connections.length > 0);
|
|
304
|
+
const unhealthyWarnings = gitHealthWithConnections.flatMap((p) => p.connections
|
|
305
|
+
.filter((c) => !c.healthy)
|
|
306
|
+
.map((c) => `- Project ${p.projectId}: connection "${c.name}" (${c.provider}) is unhealthy - ${GIT_HEALTH_REASON_TEXT[c.reason ?? ''] ?? c.reason ?? 'unknown reason'}. If closing a ticket here depends on commit/PR ingestion, verify manually - ingestion may be stalled.`));
|
|
307
|
+
// ORB-1607 - the optional one-shot ticket bundle, built after the
|
|
308
|
+
// rest so it doesn't hold up the core digest on a slow primer render.
|
|
309
|
+
const bundle = input.ticketKey ? await buildTicketBundle(client, input.ticketKey) : null;
|
|
310
|
+
// ORB-1697 - when the caller named the ticket it is working, the
|
|
311
|
+
// cross-project assigned list is noise: it measured 3.5k characters of
|
|
312
|
+
// other projects' tickets on a real call. List the ones in the same
|
|
313
|
+
// project, count the rest so nothing is hidden.
|
|
314
|
+
const scopeProjectId = bundle?.projectId;
|
|
315
|
+
const scopedTickets = scopeProjectId
|
|
316
|
+
? tickets.filter((t) => !t.projectId || t.projectId === scopeProjectId)
|
|
317
|
+
: tickets;
|
|
318
|
+
const elsewhereCount = tickets.length - scopedTickets.length;
|
|
319
|
+
const lines = ['# orboto session start'];
|
|
320
|
+
if (me)
|
|
321
|
+
lines.push(`You are ${me.fullName ?? me.email}${me.email ? ` (${me.email})` : ''}.`);
|
|
322
|
+
if (me?.workspaceLocale || me?.locale)
|
|
323
|
+
lines.push(`Write tickets / comments / docs in: ${me.workspaceLocale ?? me.locale}.`);
|
|
324
|
+
// ORB-1818 - three delivery modes for the rules, cheapest first:
|
|
325
|
+
// ack - this connection already delivered this exact hash.
|
|
326
|
+
// index - one line per rule + the hash + a handle; the default.
|
|
327
|
+
// full - the text inline (forceRules, or an API too old to send
|
|
328
|
+
// an index - never leave a caller without its rules).
|
|
329
|
+
const rulesText = rules.instructions?.trim() ?? '';
|
|
330
|
+
const rulesIndex = Array.isArray(rules.rulesIndex)
|
|
331
|
+
? rules.rulesIndex.map((e) => e?.title).filter((t) => typeof t === 'string' && t.length > 0)
|
|
332
|
+
: [];
|
|
333
|
+
const deliverIndex = !rules.rulesUnchanged && input.forceRules !== true && rulesIndex.length > 0 && rulesText.length > 0;
|
|
334
|
+
const deliverFull = !rules.rulesUnchanged && !deliverIndex && rulesText.length > 0;
|
|
335
|
+
const rulesChars = rules.rulesChars ?? rulesText.length;
|
|
336
|
+
// The way back, spelled out for the structured half too: Claude Code
|
|
337
|
+
// keeps `structuredContent` and drops the text block, so a pointer
|
|
338
|
+
// that lives only in the Markdown reaches half the clients.
|
|
339
|
+
const HOW_TO_READ_RULES = 'Call orboto_session_start { rulesOnly: true } for the complete rule text (one call, never truncated). '
|
|
340
|
+
+ 'Do that whenever you do not already hold this exact rulesHash - after a compaction, a /clear, or as a fresh agent.';
|
|
341
|
+
let rulesHandle;
|
|
342
|
+
lines.push('', '## Working rules - follow these');
|
|
343
|
+
if (rules.rulesUnchanged) {
|
|
344
|
+
// ORB-1697 - never assert that the caller still HAS the rules. This
|
|
345
|
+
// connection delivered them once, which says nothing about whether
|
|
346
|
+
// they survived a compaction or a /clear on the client side.
|
|
347
|
+
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.');
|
|
348
|
+
}
|
|
349
|
+
else if (deliverIndex) {
|
|
350
|
+
// The full text stays one call away in BOTH directions: statelessly
|
|
351
|
+
// via rulesOnly, and via the standard ORB-1697 expand handle while
|
|
352
|
+
// this process still holds it.
|
|
353
|
+
rulesHandle = storePayload('orboto_session_start', {
|
|
354
|
+
structuredContent: { rules: rules.instructions ?? '' },
|
|
355
|
+
text: rules.instructions ?? '',
|
|
356
|
+
omitted: [{ path: 'rules', kind: 'string', omittedChars: rulesText.length }],
|
|
357
|
+
});
|
|
358
|
+
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}`));
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
lines.push(rulesText || '(no workspace rules configured)');
|
|
362
|
+
}
|
|
363
|
+
lines.push('', '## Your in-progress work');
|
|
364
|
+
if (tickets.length === 0)
|
|
365
|
+
lines.push('No tickets currently assigned to you - claim or create one before you start coding.');
|
|
366
|
+
else {
|
|
367
|
+
for (const t of scopedTickets) {
|
|
368
|
+
// ORB-1799 - the flag is the whole point of listing this ticket
|
|
369
|
+
// again, so it goes on the SAME line, not into a separate section
|
|
370
|
+
// a compaction-recovering agent might skim past.
|
|
371
|
+
const landed = t.landedIdle
|
|
372
|
+
? ` - LANDED, IDLE ${t.landedIdleWorkingDays ?? '?'} working day(s): a commit is linked but the ticket never moved. Verify it is finished, then move it to review (orboto_work_finish / move_ticket) instead of re-implementing it.`
|
|
373
|
+
: '';
|
|
374
|
+
lines.push(`- ${t.ticketKey} [${t.statusName ?? t.status}] ${t.title}${landed}`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (elsewhereCount > 0) {
|
|
378
|
+
lines.push(`(+ ${elsewhereCount} open ticket(s) assigned to you in other projects - call orboto_my_tickets to list them.)`);
|
|
379
|
+
}
|
|
380
|
+
if (unhealthyWarnings.length > 0) {
|
|
381
|
+
lines.push('', '## Git connection health - WARNING', ...unhealthyWarnings);
|
|
382
|
+
}
|
|
383
|
+
lines.push('', '## Timer');
|
|
384
|
+
lines.push(timer?.ticketId ? `Running on ${timer.ticketKey ?? timer.ticketId} since ${timer.startedAt ?? 'earlier'}.` : 'No timer running.');
|
|
385
|
+
if (pendingMessages.length > 0) {
|
|
386
|
+
lines.push('', '## Agent messages - unread');
|
|
387
|
+
for (const m of pendingMessages)
|
|
388
|
+
lines.push(`- [${m.kind}] ${m.subject} (from ${m.fromUserId}, ${m.createdAt}, id ${m.id})`);
|
|
389
|
+
lines.push('Handle them, then acknowledge via orboto_messages { ackIds: [...] }; reply via orboto_agent_notify with threadId.');
|
|
390
|
+
}
|
|
391
|
+
if (bundle)
|
|
392
|
+
lines.push(...bundle.lines);
|
|
393
|
+
lines.push('', 'Re-run this after any context compaction to re-sync.');
|
|
394
|
+
return {
|
|
395
|
+
// ORB-1818 - when the full rule text IS the payload, neither half
|
|
396
|
+
// may be cut; see PROTECT_TEXT_META. The index answer carries no
|
|
397
|
+
// rule text and is budgeted like any other response.
|
|
398
|
+
...(deliverFull ? { _meta: { [PROTECT_TEXT_META]: true } } : {}),
|
|
399
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
400
|
+
structuredContent: {
|
|
401
|
+
// ORB-1818 - empty in index/ack mode: the whole point is that the
|
|
402
|
+
// text is not carried. `rulesDelivery` says which answer this is.
|
|
403
|
+
rules: deliverFull ? (rules.instructions ?? '') : '',
|
|
404
|
+
rulesHash: rules.rulesHash ?? null,
|
|
405
|
+
rulesUnchanged: rules.rulesUnchanged === true,
|
|
406
|
+
rulesDelivery: rules.rulesUnchanged ? 'ack' : deliverIndex ? 'index' : 'full',
|
|
407
|
+
...(deliverIndex
|
|
408
|
+
? { rulesIndex, rulesChars, rulesHandle, rulesHowToRead: HOW_TO_READ_RULES }
|
|
409
|
+
: {}),
|
|
410
|
+
...(rules.rulesUnchanged ? { rulesHowToRead: HOW_TO_READ_RULES } : {}),
|
|
411
|
+
inProgress: scopedTickets.map((t) => ({
|
|
412
|
+
ticketKey: t.ticketKey,
|
|
413
|
+
title: t.title,
|
|
414
|
+
status: t.statusName ?? t.status ?? null,
|
|
415
|
+
// ORB-1799 - only carried when true; a `false` on every row is
|
|
416
|
+
// budget spent on "nothing to see here" (see the MCP response
|
|
417
|
+
// budget contract in CLAUDE.md).
|
|
418
|
+
...(t.landedIdle ? { landedIdle: true, landedIdleWorkingDays: t.landedIdleWorkingDays ?? null } : {}),
|
|
419
|
+
})),
|
|
420
|
+
...(elsewhereCount > 0 ? { inProgressElsewhereCount: elsewhereCount } : {}),
|
|
421
|
+
timer: timer?.ticketId ? { ticketKey: timer.ticketKey ?? null, startedAt: timer.startedAt ?? null } : null,
|
|
422
|
+
// ORB-1697 - unhealthy connections only; a healthy one is 11 fields
|
|
423
|
+
// the agent never acts on. `healthyCount` keeps the fact visible.
|
|
424
|
+
gitHealth: {
|
|
425
|
+
unhealthy: gitHealthWithConnections
|
|
426
|
+
.map((p) => ({ projectId: p.projectId, connections: p.connections.filter((c) => !c.healthy) }))
|
|
427
|
+
.filter((p) => p.connections.length > 0),
|
|
428
|
+
healthyCount: gitHealthWithConnections.reduce((n, p) => n + p.connections.filter((c) => c.healthy).length, 0),
|
|
429
|
+
},
|
|
430
|
+
...(pendingMessages.length > 0 ? { pendingMessages } : {}),
|
|
431
|
+
...(bundle ? { ticketBundle: bundle.structured } : {}),
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
};
|
|
435
|
+
}
|