@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.
Files changed (159) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +123 -0
  3. package/dist/event-bridge.js +250 -0
  4. package/dist/event-bridge.test.js +165 -0
  5. package/dist/http-transport-binding.test.js +151 -0
  6. package/dist/http-transport.js +608 -0
  7. package/dist/http-transport.test.js +408 -0
  8. package/dist/index.js +128 -0
  9. package/dist/input-schema.js +293 -0
  10. package/dist/input-schema.test.js +198 -0
  11. package/dist/instructions-budget.test.js +34 -0
  12. package/dist/manifest-size.test.js +192 -0
  13. package/dist/mcp-instrument.js +24 -0
  14. package/dist/oauth-bootstrap.js +413 -0
  15. package/dist/oauth-bootstrap.test.js +212 -0
  16. package/dist/orboto-client.js +250 -0
  17. package/dist/orboto-client.test.js +141 -0
  18. package/dist/prompts.js +219 -0
  19. package/dist/prompts.test.js +79 -0
  20. package/dist/protect-text-usage.test.js +38 -0
  21. package/dist/resources.js +260 -0
  22. package/dist/resources.test.js +191 -0
  23. package/dist/response-budget.js +515 -0
  24. package/dist/response-budget.test.js +343 -0
  25. package/dist/server.js +547 -0
  26. package/dist/session-nudge.js +81 -0
  27. package/dist/session-nudge.test.js +100 -0
  28. package/dist/session-nudge.transport.test.js +100 -0
  29. package/dist/tool-annotations.test.js +97 -0
  30. package/dist/tool-docs.js +64 -0
  31. package/dist/tool-docs.test.js +90 -0
  32. package/dist/tool-parameters.test.js +145 -0
  33. package/dist/tools/absence-writes.js +93 -0
  34. package/dist/tools/absence-writes.test.js +106 -0
  35. package/dist/tools/admin-translations.js +83 -0
  36. package/dist/tools/admin-writes.js +174 -0
  37. package/dist/tools/admin-writes.test.js +120 -0
  38. package/dist/tools/agent-coordination.js +160 -0
  39. package/dist/tools/agent-coordination.test.js +156 -0
  40. package/dist/tools/agent-drift.js +79 -0
  41. package/dist/tools/agent-drift.test.js +87 -0
  42. package/dist/tools/agent-instructions.js +172 -0
  43. package/dist/tools/agent-instructions.test.js +124 -0
  44. package/dist/tools/agent-messages.js +75 -0
  45. package/dist/tools/ai-status.js +55 -0
  46. package/dist/tools/ai-status.test.js +43 -0
  47. package/dist/tools/ai-usage.js +68 -0
  48. package/dist/tools/analytics.js +72 -0
  49. package/dist/tools/analytics.test.js +71 -0
  50. package/dist/tools/api-call.js +72 -0
  51. package/dist/tools/api-call.test.js +104 -0
  52. package/dist/tools/api-search.js +77 -0
  53. package/dist/tools/api-search.test.js +90 -0
  54. package/dist/tools/approvals.js +68 -0
  55. package/dist/tools/approvals.test.js +48 -0
  56. package/dist/tools/attach.js +108 -0
  57. package/dist/tools/attach.test.js +117 -0
  58. package/dist/tools/backup.js +93 -0
  59. package/dist/tools/backup.test.js +108 -0
  60. package/dist/tools/bulk-create.js +213 -0
  61. package/dist/tools/bulk-create.test.js +135 -0
  62. package/dist/tools/bulk-writes.js +339 -0
  63. package/dist/tools/bulk-writes.test.js +239 -0
  64. package/dist/tools/check-similar.js +84 -0
  65. package/dist/tools/check-similar.test.js +101 -0
  66. package/dist/tools/checklist-writes.js +202 -0
  67. package/dist/tools/checklist-writes.test.js +184 -0
  68. package/dist/tools/claim.js +236 -0
  69. package/dist/tools/claim.test.js +207 -0
  70. package/dist/tools/critical-path.js +83 -0
  71. package/dist/tools/critical-path.test.js +114 -0
  72. package/dist/tools/cross-project-links.ee.js +124 -0
  73. package/dist/tools/cross-project-links.ee.test.js +163 -0
  74. package/dist/tools/customer-report.js +88 -0
  75. package/dist/tools/customer-report.test.js +55 -0
  76. package/dist/tools/doc-attachments.js +184 -0
  77. package/dist/tools/doc-attachments.test.js +136 -0
  78. package/dist/tools/doc-comments.js +228 -0
  79. package/dist/tools/doc-comments.test.js +192 -0
  80. package/dist/tools/doc-edits.js +229 -0
  81. package/dist/tools/doc-edits.test.js +218 -0
  82. package/dist/tools/doc-export.js +87 -0
  83. package/dist/tools/doc-export.test.js +109 -0
  84. package/dist/tools/doc-revisions.js +133 -0
  85. package/dist/tools/doc-revisions.test.js +103 -0
  86. package/dist/tools/docs-ai.js +171 -0
  87. package/dist/tools/docs-ai.test.js +160 -0
  88. package/dist/tools/docs.js +557 -0
  89. package/dist/tools/docs.test.js +365 -0
  90. package/dist/tools/embedding-status.js +100 -0
  91. package/dist/tools/embedding-status.test.js +86 -0
  92. package/dist/tools/free-busy.js +49 -0
  93. package/dist/tools/free-busy.test.js +46 -0
  94. package/dist/tools/get-checklists.js +72 -0
  95. package/dist/tools/get-project-primer.js +58 -0
  96. package/dist/tools/get-project.js +77 -0
  97. package/dist/tools/get-ticket.js +321 -0
  98. package/dist/tools/get-timer.js +62 -0
  99. package/dist/tools/help.js +44 -0
  100. package/dist/tools/identity.js +60 -0
  101. package/dist/tools/identity.test.js +67 -0
  102. package/dist/tools/list-git-app-installations.js +45 -0
  103. package/dist/tools/list-projects.js +72 -0
  104. package/dist/tools/list-projects.test.js +67 -0
  105. package/dist/tools/list-tickets.js +86 -0
  106. package/dist/tools/milestones-crud.test.js +154 -0
  107. package/dist/tools/milestones.js +279 -0
  108. package/dist/tools/milestones.test.js +60 -0
  109. package/dist/tools/my-tickets.js +65 -0
  110. package/dist/tools/personal-facts.js +72 -0
  111. package/dist/tools/personal-facts.test.js +45 -0
  112. package/dist/tools/phase-b-tools.test.js +530 -0
  113. package/dist/tools/portfolio.js +56 -0
  114. package/dist/tools/primer-facts.js +346 -0
  115. package/dist/tools/primer-facts.test.js +353 -0
  116. package/dist/tools/project-listings.js +116 -0
  117. package/dist/tools/project-listings.test.js +106 -0
  118. package/dist/tools/query.js +65 -0
  119. package/dist/tools/query.test.js +128 -0
  120. package/dist/tools/raci.js +108 -0
  121. package/dist/tools/raci.test.js +70 -0
  122. package/dist/tools/requirements-spec.js +89 -0
  123. package/dist/tools/requirements-spec.test.js +62 -0
  124. package/dist/tools/response-expand.js +104 -0
  125. package/dist/tools/review-policy.js +105 -0
  126. package/dist/tools/review-policy.test.js +71 -0
  127. package/dist/tools/search.js +78 -0
  128. package/dist/tools/session-start.js +435 -0
  129. package/dist/tools/session-start.test.js +568 -0
  130. package/dist/tools/set-parent.js +76 -0
  131. package/dist/tools/set-parent.test.js +115 -0
  132. package/dist/tools/shared.js +226 -0
  133. package/dist/tools/shared.test.js +169 -0
  134. package/dist/tools/similar-projection.js +30 -0
  135. package/dist/tools/ticket-attachments.js +131 -0
  136. package/dist/tools/ticket-attachments.test.js +106 -0
  137. package/dist/tools/ticket-schedules.js +112 -0
  138. package/dist/tools/ticket-schedules.test.js +102 -0
  139. package/dist/tools/ticket-writes.js +778 -0
  140. package/dist/tools/ticket-writes.test.js +643 -0
  141. package/dist/tools/time-writes.js +238 -0
  142. package/dist/tools/time-writes.test.js +138 -0
  143. package/dist/tools/update-project.js +165 -0
  144. package/dist/tools/update-project.test.js +172 -0
  145. package/dist/tools/wiki.js +185 -0
  146. package/dist/tools/wiki.test.js +104 -0
  147. package/dist/tools/work-sessions-next.test.js +150 -0
  148. package/dist/tools/work-sessions-start.test.js +174 -0
  149. package/dist/tools/work-sessions.js +709 -0
  150. package/dist/tools/work-sessions.test.js +358 -0
  151. package/dist/toolset.js +149 -0
  152. package/dist/toolset.test.js +150 -0
  153. package/dist/version-guard.test.js +51 -0
  154. package/dist/version.js +28 -0
  155. package/dist/with-metrics-mail-nudge.test.js +41 -0
  156. package/dist/with-metrics-output-schema.test.js +91 -0
  157. package/dist/with-metrics.js +211 -0
  158. package/dist/with-metrics.test.js +230 -0
  159. 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
+ }