@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,709 @@
1
+ /**
2
+ * ORB-1609 - work-session tools.
3
+ *
4
+ * A work session is the transactional record behind "I am working on
5
+ * this ticket": it holds the lease, owns the timer, drives presence,
6
+ * and carries the finish-time evidence (commit + verification). It is
7
+ * the primitive `orboto work start / finish / next` (wave 3) is built
8
+ * on, and it is what makes ownership visible ACROSS accounts - a lease
9
+ * held by another team's bot is a 409 with the holder attached, not an
10
+ * invisible collision discovered at push time.
11
+ *
12
+ * The three roles that are not `implementation` deliberately do NOT
13
+ * reassign the ticket or move its status: a review / preflight /
14
+ * integration session attaches to a ticket without pretending to own
15
+ * its delivery. That is what makes one-ticket-one-commit workable for
16
+ * work that produces no commit of its own.
17
+ */
18
+ import { z } from 'zod';
19
+ import { OrbotoApiError } from '../orboto-client.js';
20
+ import { mcpInstanceToken, resolveTicketByKey } from './shared.js';
21
+ const ResourceClaimShape = {
22
+ kind: z.enum(['path', 'named']),
23
+ value: z.string().min(1).max(500),
24
+ mode: z.enum(['read', 'write']),
25
+ };
26
+ /** Shared 409-body reader: the endpoint's body carries EITHER `holder`
27
+ * (a lease conflict) OR `claimConflicts` (a resource-claim conflict) -
28
+ * never both, since the lease is checked before claims are applied. */
29
+ function parseClaimConflicts(err) {
30
+ try {
31
+ return JSON.parse(err.body).claimConflicts;
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ }
37
+ function describeClaimConflicts(conflicts) {
38
+ return conflicts
39
+ .map((c) => {
40
+ const who = c.holders
41
+ .map((h) => `${h.userFullName ?? h.userEmail ?? h.sessionId} on ${h.ticketKey ?? '(unknown ticket)'}`)
42
+ .join(', ');
43
+ return ` - ${c.claim.kind}:${c.claim.value} (${c.claim.mode}) blocked by ${who || 'an active writer'}`;
44
+ })
45
+ .join('\n');
46
+ }
47
+ function describe(s) {
48
+ const key = s.ticketKey ?? s.ticketId.slice(0, 8);
49
+ const who = s.userFullName ?? s.userEmail ?? 'unknown';
50
+ return ` - ${key} [${s.role}] ${who} - lease until ${s.leaseUntil}${s.commitSha ? ` (commit ${s.commitSha.slice(0, 8)})` : ''}`;
51
+ }
52
+ // ---------------------------------------------------------------------------
53
+ // orboto_work_start - ORB-1611
54
+ // ---------------------------------------------------------------------------
55
+ // Mirrors apps/mcp/src/tools/session-start.ts's local shapes - kept
56
+ // duplicated rather than imported, same choice that file already made for
57
+ // its own bundle types (no shared MCP-side schema layer for these).
58
+ const GIT_HEALTH_REASON_TEXT = {
59
+ connection_inactive: 'connection is deactivated',
60
+ app_installation_suspended: 'GitHub App installation is suspended',
61
+ oauth_token_expired: 'OAuth token expired with no refresh path',
62
+ history_backfill_error: 'last history backfill failed',
63
+ };
64
+ export const workStartToolConfig = {
65
+ title: 'Start a work session and load the full context bundle',
66
+ description: 'ORB-1611 - the one-call ticket pickup. Acquires the (ticket, role) work lease with the exact same guarantees as orboto_work_session_start (exactly ONE active session per ticket+role workspace-wide, a conflict names the holder, resourceClaims apply atomically with the lease), AND in the SAME response returns the rules-hash ack (same semantics as orboto_session_start), the project primer, the ticket enriched with its description/status/priority, its checklists, its dependencies, that project\'s git connection health, and any other live sessions already on the ticket. This replaces the 8-15 separate calls (orboto_session_start, orboto_get_project_primer, orboto_get_ticket, orboto_get_checklists, orboto_list_ticket_dependencies, ...) a normal ticket pickup used to cost. Prefer this over orboto_work_session_start for picking up a ticket; use the plain tool only when you deliberately do not want the bundle (e.g. a mid-task lease renewal where you already have fresh context). A conflict never leaves a partial session behind - same rollback-on-conflict guarantee as orboto_work_session_start.',
67
+ inputSchema: z.object({
68
+ ticketKey: z.string().min(3).describe('Ticket key like "ACME-42".'),
69
+ role: z.enum(['implementation', 'review', 'preflight', 'integration']).optional()
70
+ .describe('Default `implementation`. Use `review` for a review pass, `preflight` for a pre-work check, `integration` for merge/release work - those attach without reassigning the ticket.'),
71
+ leaseSeconds: z.number().int().min(60).max(86_400).optional()
72
+ .describe('How long the lease should hold without renewal. Default 900 (15 min).'),
73
+ takeover: z.boolean().optional()
74
+ .describe('Displace the current holder of this (ticket, role) lease. Their session is cancelled and their tracked time booked.'),
75
+ startTimer: z.boolean().optional()
76
+ .describe('Defaults to true for `implementation`, false for the attach-only roles.'),
77
+ agentSessionToken: z.string().optional()
78
+ .describe('Stable per-agent-instance token. Omit to use this MCP connection\'s own instance id.'),
79
+ resourceClaims: z.array(z.object(ResourceClaimShape)).max(50).optional()
80
+ .describe('ORB-1610 - resource claims to acquire alongside the lease. Same semantics as orboto_work_session_start\'s resourceClaims.'),
81
+ onConflict: z.enum(['reject', 'queue']).optional()
82
+ .describe('Only matters when `resourceClaims` is set. Default `reject`.'),
83
+ }).shape,
84
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
85
+ };
86
+ export function makeWorkStartHandler(client) {
87
+ // Per-connection rules-hash cache, mirrored from session-start.ts's
88
+ // makeSessionStartHandler - this closure lives for one MCP connection.
89
+ let lastKnownRulesHash;
90
+ return async (args, extra) => {
91
+ const token = mcpInstanceToken(args.agentSessionToken, extra);
92
+ let ticketId;
93
+ try {
94
+ ticketId = (await resolveTicketByKey(client, args.ticketKey)).id;
95
+ }
96
+ catch (err) {
97
+ const message = err instanceof Error ? err.message : String(err);
98
+ return { content: [{ type: 'text', text: message }], isError: true };
99
+ }
100
+ try {
101
+ const res = await client.post('/work-sessions/start', {
102
+ ticketId,
103
+ ...(args.role ? { role: args.role } : {}),
104
+ ...(args.leaseSeconds ? { leaseSeconds: args.leaseSeconds } : {}),
105
+ ...(args.takeover ? { takeover: true } : {}),
106
+ ...(args.startTimer !== undefined ? { startTimer: args.startTimer } : {}),
107
+ ...(args.resourceClaims && args.resourceClaims.length > 0 ? { resourceClaims: args.resourceClaims } : {}),
108
+ ...(args.onConflict ? { onConflict: args.onConflict } : {}),
109
+ agentSessionToken: token,
110
+ ...(lastKnownRulesHash ? { knownRulesHash: lastKnownRulesHash } : {}),
111
+ });
112
+ if (res.rulesHash)
113
+ lastKnownRulesHash = res.rulesHash;
114
+ const lines = [
115
+ res.reused
116
+ ? `Renewed your existing ${res.session.role} session on ${args.ticketKey}.`
117
+ : `Started a ${res.session.role} work session on ${args.ticketKey}.`,
118
+ `Session id: ${res.session.id} (pass this to orboto_work_session_finish).`,
119
+ `Lease held until ${res.session.leaseUntil}; it renews automatically while you keep calling orboto.`,
120
+ res.session.activeTimerId ? 'Timer running.' : 'No timer started for this role.',
121
+ ];
122
+ if (res.displaced) {
123
+ lines.push(`Displaced ${res.displaced.userFullName ?? res.displaced.userEmail ?? 'the previous holder'} (session ${res.displaced.sessionId}); their tracked time was booked.`);
124
+ }
125
+ if (res.queued && res.queued.length > 0) {
126
+ lines.push('Queued (waiting for a conflicting writer to release):', ...res.queued.map((q) => ` - ${q.claim.kind}:${q.claim.value} - position ${q.position}`));
127
+ }
128
+ lines.push('', '## Working rules');
129
+ if (res.rulesUnchanged) {
130
+ lines.push(`Unchanged since your last work-start on this connection (hash ${res.rulesHash}) - keep following what you already loaded.`);
131
+ }
132
+ else {
133
+ lines.push(res.rules?.trim() || '(no workspace rules configured)');
134
+ }
135
+ lines.push('', '## Project primer');
136
+ lines.push(res.primer.markdown.trim() || '(primer unavailable)');
137
+ lines.push('', `## Ticket: ${res.ticket.ticketKey ?? args.ticketKey}`);
138
+ lines.push(res.ticket.title);
139
+ lines.push(`Status: ${res.ticket.statusName ?? res.ticket.status} Priority: ${res.ticket.priority} Type: ${res.ticket.type}`);
140
+ if (res.ticket.description)
141
+ lines.push('', res.ticket.description);
142
+ lines.push('', '## Checklists');
143
+ if (res.checklists.length === 0) {
144
+ lines.push('(none)');
145
+ }
146
+ else {
147
+ for (const cl of res.checklists) {
148
+ lines.push(`${cl.title} (${cl.progress.done}/${cl.progress.total})${cl.triggersDone ? ' - triggers done' : ''}`);
149
+ for (const i of cl.items) {
150
+ const link = i.linkedTicketKey ? ` -> [${i.linkedTicketKey}] (${i.linkedTicketStatusCategory ?? 'unknown'})` : '';
151
+ lines.push(`- [${i.effectiveCompleted ? 'x' : ' '}] ${i.content}${link}`);
152
+ }
153
+ }
154
+ }
155
+ // ORB-1614 - a cross-project blocker/dependent the caller cannot read
156
+ // comes back with `title: null` - render a fixed placeholder instead
157
+ // of the literal "null".
158
+ 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');
159
+ lines.push('', '## Dependencies');
160
+ lines.push('Blocked by:', fmtDeps(res.dependencies.blockedBy), 'Blocks:', fmtDeps(res.dependencies.blocks));
161
+ const unhealthy = res.gitHealth.filter((c) => !c.healthy);
162
+ if (unhealthy.length > 0) {
163
+ lines.push('', '## Git connection health - WARNING');
164
+ for (const c of unhealthy) {
165
+ lines.push(`- "${c.name}" (${c.provider}) - ${GIT_HEALTH_REASON_TEXT[c.reason ?? ''] ?? c.reason ?? 'unknown reason'}`);
166
+ }
167
+ }
168
+ lines.push('', '## Other sessions on this ticket');
169
+ lines.push(res.siblingSessions.length === 0
170
+ ? '(none)'
171
+ : res.siblingSessions.map((s) => ` - [${s.role}] ${s.userFullName ?? s.userEmail ?? 'unknown'} - lease until ${s.leaseUntil}`).join('\n'));
172
+ return {
173
+ content: [{ type: 'text', text: lines.join('\n') }],
174
+ structuredContent: {
175
+ session: res.session,
176
+ reused: res.reused,
177
+ displaced: res.displaced ?? null,
178
+ queued: res.queued ?? [],
179
+ rulesHash: res.rulesHash,
180
+ rulesUnchanged: res.rulesUnchanged,
181
+ primer: res.primer,
182
+ ticket: res.ticket,
183
+ checklists: res.checklists,
184
+ dependencies: res.dependencies,
185
+ gitHealth: res.gitHealth,
186
+ siblingSessions: res.siblingSessions,
187
+ },
188
+ };
189
+ }
190
+ catch (err) {
191
+ if (err instanceof OrbotoApiError && err.status === 409) {
192
+ const claimConflicts = parseClaimConflicts(err);
193
+ if (claimConflicts && claimConflicts.length > 0) {
194
+ return {
195
+ content: [{
196
+ type: 'text',
197
+ text: `One or more resource claims for ${args.ticketKey} conflict with an active writer:\n` +
198
+ `${describeClaimConflicts(claimConflicts)}\n` +
199
+ 'Re-run with onConflict="queue" to wait instead of failing, or narrow the glob.',
200
+ }],
201
+ structuredContent: { claimConflict: true, claimConflicts },
202
+ isError: true,
203
+ };
204
+ }
205
+ let holder;
206
+ try {
207
+ holder = JSON.parse(err.body).holder;
208
+ }
209
+ catch {
210
+ holder = undefined;
211
+ }
212
+ const who = holder ? (holder.userFullName ?? holder.userEmail ?? holder.sessionId) : 'another agent';
213
+ const until = holder ? holder.leaseUntil : 'unknown';
214
+ return {
215
+ content: [{
216
+ type: 'text',
217
+ text: `The ${args.role ?? 'implementation'} lease on ${args.ticketKey} is held by ${who} until ${until}.\n` +
218
+ 'Pick a different ticket, attach in another role (e.g. role="review"), wait for the lease to expire, ' +
219
+ 'or re-run with takeover=true if you have decided to displace them.',
220
+ }],
221
+ structuredContent: { conflict: true, holder: holder ?? null },
222
+ isError: true,
223
+ };
224
+ }
225
+ throw err;
226
+ }
227
+ };
228
+ }
229
+ // ---------------------------------------------------------------------------
230
+ // orboto_work_session_start
231
+ // ---------------------------------------------------------------------------
232
+ export const workSessionStartToolConfig = {
233
+ title: 'Start (or renew) a work session on a ticket',
234
+ description: 'Take the work lease on a ticket in a given role and start its timer. This is the coordination primitive: exactly ONE active session per (ticket, role) exists workspace-wide, so a second agent attempting the same role gets a conflict naming the current holder instead of silently colliding. Re-calling with the same agent instance renews your own lease and is a no-op otherwise - safe to call defensively. Roles other than `implementation` (review / preflight / integration) attach to the ticket WITHOUT reassigning it or moving its status, so a reviewing agent no longer has to fake a claim. The lease expires on its own (default 15 min, renewed automatically by your subsequent calls), so a crashed agent never wedges a ticket. Pass `takeover: true` only when you have decided to displace the current holder - their session is closed and their time booked, and the takeover is visible in history.',
235
+ inputSchema: z.object({
236
+ ticketKey: z.string().min(3).describe('Ticket key like "ACME-42".'),
237
+ role: z.enum(['implementation', 'review', 'preflight', 'integration']).optional()
238
+ .describe('Default `implementation`. Use `review` for a review pass, `preflight` for a pre-work check, `integration` for merge/release work - those attach without reassigning the ticket.'),
239
+ leaseSeconds: z.number().int().min(60).max(86_400).optional()
240
+ .describe('How long the lease should hold without renewal. Default 900 (15 min).'),
241
+ takeover: z.boolean().optional()
242
+ .describe('Displace the current holder of this (ticket, role) lease. Their session is cancelled and their tracked time booked.'),
243
+ startTimer: z.boolean().optional()
244
+ .describe('Defaults to true for `implementation`, false for the attach-only roles.'),
245
+ agentSessionToken: z.string().optional()
246
+ .describe('Stable per-agent-instance token. Omit to use this MCP connection\'s own instance id.'),
247
+ resourceClaims: z.array(z.object(ResourceClaimShape)).max(50).optional()
248
+ .describe('ORB-1610 - resource claims to acquire alongside the lease. `kind: "path"` takes a glob relative to the repo root (`src/**`, `apps/api/src/routes/tickets.ts`); `kind: "named"` takes an opaque exclusive-resource id (`unity-editor:main`, `git-push:orboto#develop`), compared by exact string equality. `mode: "write"` conflicts with any OVERLAPPING active write claim workspace-wide (across tickets and accounts) - editor refresh clobbering uncommitted changes and concurrent pushes staging each other\'s files are exactly what this prevents. `mode: "read"` never conflicts with anything, including another read.'),
249
+ onConflict: z.enum(['reject', 'queue']).optional()
250
+ .describe('Only matters when `resourceClaims` is set. Default `reject`: a conflicting write claim fails the WHOLE call with the conflicting holder(s) named - if this call would have created a brand-new session, that session is rolled back rather than left holding the lease without its claims. `queue`: the conflicting claim is accepted as `state: "waiting"` instead of failing; it is promoted automatically once the conflict clears (release, finish, or the next orboto_work_sessions read).'),
251
+ }).shape,
252
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
253
+ };
254
+ export function makeWorkSessionStartHandler(client) {
255
+ return async (args, extra) => {
256
+ const ticket = await resolveTicketByKey(client, args.ticketKey);
257
+ const token = mcpInstanceToken(args.agentSessionToken, extra);
258
+ try {
259
+ const res = await client.post('/work-sessions', {
260
+ ticketId: ticket.id,
261
+ ...(args.role ? { role: args.role } : {}),
262
+ ...(args.leaseSeconds ? { leaseSeconds: args.leaseSeconds } : {}),
263
+ ...(args.takeover ? { takeover: true } : {}),
264
+ ...(args.startTimer !== undefined ? { startTimer: args.startTimer } : {}),
265
+ ...(args.resourceClaims && args.resourceClaims.length > 0 ? { resourceClaims: args.resourceClaims } : {}),
266
+ ...(args.onConflict ? { onConflict: args.onConflict } : {}),
267
+ agentSessionToken: token,
268
+ });
269
+ const lines = [
270
+ res.reused
271
+ ? `Renewed your existing ${res.session.role} session on ${args.ticketKey}.`
272
+ : `Started a ${res.session.role} work session on ${args.ticketKey}.`,
273
+ `Session id: ${res.session.id} (pass this to orboto_work_session_finish).`,
274
+ `Lease held until ${res.session.leaseUntil}; it renews automatically while you keep calling orboto.`,
275
+ res.session.activeTimerId ? 'Timer running.' : 'No timer started for this role.',
276
+ ];
277
+ if (res.displaced) {
278
+ lines.push(`Displaced ${res.displaced.userFullName ?? res.displaced.userEmail ?? 'the previous holder'} (session ${res.displaced.sessionId}); their tracked time was booked.`);
279
+ }
280
+ if (res.queued && res.queued.length > 0) {
281
+ lines.push('Queued (waiting for a conflicting writer to release):', ...res.queued.map((q) => ` - ${q.claim.kind}:${q.claim.value} - position ${q.position}`));
282
+ }
283
+ return {
284
+ content: [{ type: 'text', text: lines.join('\n') }],
285
+ structuredContent: { session: res.session, reused: res.reused, displaced: res.displaced ?? null, queued: res.queued ?? [] },
286
+ };
287
+ }
288
+ catch (err) {
289
+ if (err instanceof OrbotoApiError && err.status === 409) {
290
+ const claimConflicts = parseClaimConflicts(err);
291
+ if (claimConflicts && claimConflicts.length > 0) {
292
+ // ORB-1610 - a resourceClaims conflict, distinct from the lease
293
+ // conflict below: the body carries `claimConflicts`, not `holder`.
294
+ return {
295
+ content: [{
296
+ type: 'text',
297
+ text: `One or more resource claims for ${args.ticketKey} conflict with an active writer:\n` +
298
+ `${describeClaimConflicts(claimConflicts)}\n` +
299
+ 'Re-run with onConflict="queue" to wait instead of failing, or narrow the glob.',
300
+ }],
301
+ structuredContent: { claimConflict: true, claimConflicts },
302
+ isError: true,
303
+ };
304
+ }
305
+ // OrbotoApiError carries the raw body string; the 409 payload is the
306
+ // standard i18n error triple plus `holder`, which is the whole point
307
+ // of the conflict response - the caller must not need a second call
308
+ // to learn who has the ticket.
309
+ let holder;
310
+ try {
311
+ holder = JSON.parse(err.body).holder;
312
+ }
313
+ catch {
314
+ holder = undefined;
315
+ }
316
+ const who = holder ? (holder.userFullName ?? holder.userEmail ?? holder.sessionId) : 'another agent';
317
+ const until = holder ? holder.leaseUntil : 'unknown';
318
+ return {
319
+ content: [{
320
+ type: 'text',
321
+ text: `The ${args.role ?? 'implementation'} lease on ${args.ticketKey} is held by ${who} until ${until}.\n` +
322
+ 'Pick a different ticket, attach in another role (e.g. role="review"), wait for the lease to expire, ' +
323
+ 'or re-run with takeover=true if you have decided to displace them.',
324
+ }],
325
+ structuredContent: { conflict: true, holder: holder ?? null },
326
+ isError: true,
327
+ };
328
+ }
329
+ throw err;
330
+ }
331
+ };
332
+ }
333
+ // ---------------------------------------------------------------------------
334
+ // orboto_work_session_finish
335
+ // ---------------------------------------------------------------------------
336
+ export const workSessionFinishToolConfig = {
337
+ title: 'Finish a work session (books time, frees the lease)',
338
+ description: 'End a work session: its timer is stopped and booked, the (ticket, role) lease is released for the next agent, and the evidence you pass (commit sha + which gates you ran) is recorded on the session. Idempotent - finishing an already-finished session succeeds and still absorbs late evidence, so a retrying harness never has to distinguish "already done" from "failed". This does NOT close the ticket; ticket status is a separate, deliberate decision (use orboto_close_ticket once the acceptance criteria are verified). Prefer orboto_work_finish (ORB-1612) for an `implementation` session that is actually done - it does this AND the ticket transition AND the completion note in one call.',
339
+ inputSchema: z.object({
340
+ sessionId: z.string().uuid().describe('The id returned by orboto_work_session_start.'),
341
+ outcome: z.enum(['finished', 'cancelled']).optional()
342
+ .describe('`finished` (default) = the work completed. `cancelled` = abandoned attempt; time is still booked, history stays honest.'),
343
+ commitSha: z.string().optional().describe('The commit this session produced, when it produced one.'),
344
+ verification: z.object({
345
+ build: z.boolean().optional(),
346
+ tests: z.boolean().optional(),
347
+ lint: z.boolean().optional(),
348
+ notes: z.string().optional(),
349
+ }).optional().describe('Which gates you actually ran and what they said. This is the attestation a reviewer reads cold.'),
350
+ }).shape,
351
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
352
+ };
353
+ export function makeWorkSessionFinishHandler(client) {
354
+ return async (args) => {
355
+ const res = await client.post(`/work-sessions/${args.sessionId}/finish`, {
356
+ ...(args.outcome ? { outcome: args.outcome } : {}),
357
+ ...(args.commitSha ? { commitSha: args.commitSha } : {}),
358
+ ...(args.verification ? { verification: args.verification } : {}),
359
+ });
360
+ const text = res.changed
361
+ ? `Session ${args.sessionId} ${res.session.status}. Booked ${res.durationMinutes} min; the ${res.session.role} lease on ${res.session.ticketKey ?? res.session.ticketId} is free.`
362
+ : `Session ${args.sessionId} was already ${res.session.status} - nothing to book. (Idempotent finish.)`;
363
+ return {
364
+ content: [{ type: 'text', text }],
365
+ structuredContent: { session: res.session, durationMinutes: res.durationMinutes, changed: res.changed },
366
+ };
367
+ };
368
+ }
369
+ export const workFinishToolConfig = {
370
+ title: 'Finish a work session AND close out the ticket in one call',
371
+ description: 'ORB-1612 - the one-call ticket exit, sibling of orboto_work_start on the entry side. Does everything orboto_work_session_finish does (stops and books the session\'s EXACT timer, releases the lease and every resource claim, idles your presence) PLUS: records a commitSha as an ATTESTATION when git ingestion has not seen it yet (never blocks - verification reconciles asynchronously once the webhook/backfill lands, so a lagging git connection can never wedge a ticket in review for hours), transitions the ticket per the ORB-1608 deliveryMode policy, and posts the completion note. Only an `implementation`-role session with outcome `finished` drives the ticket (default target category `done`) - `review`/`preflight`/`integration` sessions attach without reassigning the ticket (ORB-1609) and a `cancelled` outcome never auto-closes a ticket nobody actually finished; both still book time and free the lease. Idempotent: finishing an already-finished session, or one whose ticket is already at the target category, is a no-op result - not an error. A blocked transition (approval gate, dependency blocker, missing permission) is non-fatal: the session still finished, `ticketTransitioned` comes back false, and the ticket is left for a human or a follow-up call.',
372
+ inputSchema: z.object({
373
+ sessionId: z.string().uuid().describe('The id returned by orboto_work_start / orboto_work_session_start.'),
374
+ outcome: z.enum(['finished', 'cancelled']).optional()
375
+ .describe('`finished` (default) drives the ticket transition below. `cancelled` = abandoned attempt; time is still booked, the ticket is left untouched.'),
376
+ commitSha: z.string().optional().describe('The commit this session produced. Recorded as an attestation immediately, verified asynchronously once git ingestion catches up - never blocks this call.'),
377
+ verification: z.object({
378
+ build: z.boolean().optional(),
379
+ tests: z.boolean().optional(),
380
+ lint: z.boolean().optional(),
381
+ notes: z.string().optional(),
382
+ }).optional().describe('Which gates you actually ran and what they said. This is the attestation a reviewer reads cold.'),
383
+ targetCategory: z.enum(['todo', 'in_progress', 'in_review', 'done', 'wont_fix']).optional()
384
+ .describe('Default `done`. Use `in_review` when a human should look at it first. Only applied for an implementation session with outcome `finished`.'),
385
+ note: z.string().optional().describe('The completion note posted on the ticket. Auto-generated (booked time + commit + verification summary) when omitted.'),
386
+ }).shape,
387
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
388
+ };
389
+ export function makeWorkFinishHandler(client) {
390
+ return async (args) => {
391
+ const res = await client.post(`/work-sessions/${args.sessionId}/finish-work`, {
392
+ ...(args.outcome ? { outcome: args.outcome } : {}),
393
+ ...(args.commitSha ? { commitSha: args.commitSha } : {}),
394
+ ...(args.verification ? { verification: args.verification } : {}),
395
+ ...(args.targetCategory ? { targetCategory: args.targetCategory } : {}),
396
+ ...(args.note ? { note: args.note } : {}),
397
+ });
398
+ const lines = [
399
+ res.changed
400
+ ? `Session ${args.sessionId} ${res.session.status}. Booked ${res.durationMinutes} min; the ${res.session.role} lease on ${res.session.ticketKey ?? res.session.ticketId} is free.`
401
+ : `Session ${args.sessionId} was already ${res.session.status} - nothing to book. (Idempotent finish.)`,
402
+ ];
403
+ if (res.session.commitSha) {
404
+ lines.push(`Commit ${res.session.commitSha}${res.session.commitVerified ? ' (verified by git ingestion)' : ' (attested - pending git verification)'}.`);
405
+ }
406
+ lines.push(res.ticketTransitioned
407
+ ? `Ticket moved to ${res.ticketStatusCategory}.`
408
+ : `Ticket left at ${res.ticketStatusCategory ?? 'its current status'} - not transitioned this call.`);
409
+ if (res.deliveryModeWarning)
410
+ lines.push(`⚠ ${res.deliveryModeWarning.message}`);
411
+ lines.push(res.noteCommented ? 'Completion note posted.' : 'No completion note posted (nothing new to report).');
412
+ return {
413
+ content: [{ type: 'text', text: lines.join('\n') }],
414
+ structuredContent: {
415
+ session: res.session,
416
+ durationMinutes: res.durationMinutes,
417
+ changed: res.changed,
418
+ ticketTransitioned: res.ticketTransitioned,
419
+ ticketStatusCategory: res.ticketStatusCategory,
420
+ deliveryModeWarning: res.deliveryModeWarning ?? null,
421
+ noteCommented: res.noteCommented,
422
+ },
423
+ };
424
+ };
425
+ }
426
+ /** ORB-1799 - rendered identically on every exit (reserved, empty, paused):
427
+ * an agent that just pulled fresh work is exactly the one about to forget
428
+ * the ticket it already finished. Empty array renders nothing. */
429
+ function landedIdleLines(rows) {
430
+ if (!rows || rows.length === 0)
431
+ return [];
432
+ return [
433
+ '',
434
+ '## Landed, idle - finished-looking work of yours awaiting the status move',
435
+ ...rows.map((r) => `- ${r.ticketKey ?? r.ticketId} "${r.title}" - ${r.commitCount} commit(s) linked, no activity for ${r.idleWorkingDays} working day(s) (since ${r.lastActivityAt}).`),
436
+ 'Verify each is actually finished, then move it into the review lane (orboto_work_finish with targetCategory "in_review", or orboto_move_ticket) - do NOT re-implement it.',
437
+ ];
438
+ }
439
+ export const workNextToolConfig = {
440
+ title: 'Pull and reserve the next ready ticket (worker-pool dispatch)',
441
+ description: 'ORB-1613 - the pull side of low-management dispatch, sibling of orboto_work_start on the "I already know which ticket" side. Picks the highest-priority ticket in a project that is READY FOR THE REQUESTED ROLE (default role implementation pulls TODO tickets; `role: "review"` pulls tickets in the in_review status category instead, and NEVER offers a ticket the caller itself implemented - the review lane primitive, ORB-1777), unblocked (every dependency closed), not already leased under the requested role, and not blocked by a conflicting resourceClaim - then reserves it with the EXACT same guarantees as orboto_work_start (atomic lease acquire + the full context bundle: rules ack, primer, ticket, checklists, dependencies, git health, siblings) in the same response. Priority then ticket number, deterministic - never a coin flip on ties. Two workers calling this concurrently never receive the SAME ticket: the underlying reservation is the identical partial-unique-index INSERT orboto_work_start uses, just walked across an ordered candidate list - a collision just advances to the next candidate. Epics are never returned (they are containers, not directly implementable). When nothing is ready, the response is a STRUCTURED result (`reserved: null`) with a `reason` (`none-matching` = no todo tickets at all; `all-blocked` = candidates exist but all have open dependencies; `all-leased` = ready candidates exist but are all currently leased or claim-conflicted; `autonomy_paused` = ORB-1774, this agent identity or the whole workspace has autonomous pulls paused by an operator - idle and wait for an operator/notify wakeup instead of retrying) and, ONLY when derivable from an actual active lease, a `retryAfterSeconds` backoff hint (`earliestFreeAt` null and `retryAfterSeconds` null means no signal exists - never a fabricated constant). This is never an error - a worker pool should back off on the hint rather than poll hot. Prefer this over orboto_work_start whenever the caller does not care WHICH ticket it gets, only that it gets the best available one right now. Every response - reserved, empty or paused - also carries `landedIdle`: YOUR OWN tickets in this project that are still in_progress with a linked commit and no activity for days (ORB-1799). Those are finished-looking work nobody handed to the review lane; move them on instead of re-implementing them.',
442
+ inputSchema: z.object({
443
+ projectKey: z.string().min(1).describe('Project key (e.g. "ACME") or UUID.'),
444
+ agentTag: z.string().min(1).max(64).optional()
445
+ .describe('ORB-1772 - preferred-not-exclusive routing tag: tickets labeled `agent:<tag>` rank first for a matching caller, foreign `agent:*` tags rank last but stay eligible. Lowercased server-side. Set it to this worker\'s routing tag (often the model or fleet lane name).'),
446
+ role: z.enum(['implementation', 'review', 'preflight', 'integration']).optional()
447
+ .describe('Default `implementation`. The dispatcher only reserves a ticket whose (ticket, role) lease is free for THIS role.'),
448
+ leaseSeconds: z.number().int().min(60).max(86_400).optional()
449
+ .describe('How long the lease should hold without renewal. Default 900 (15 min).'),
450
+ startTimer: z.boolean().optional()
451
+ .describe('Defaults to true for `implementation`, false for the attach-only roles.'),
452
+ agentSessionToken: z.string().optional()
453
+ .describe('Stable per-agent-instance token. Omit to use this MCP connection\'s own instance id.'),
454
+ resourceClaims: z.array(z.object(ResourceClaimShape)).max(50).optional()
455
+ .describe('ORB-1610 - resource claims to acquire alongside the reservation, AND to filter candidates: a ticket already held under a conflicting GRANTED write claim elsewhere is skipped even when its (ticket, role) lease is free.'),
456
+ onConflict: z.enum(['reject', 'queue']).optional()
457
+ .describe('Only matters when `resourceClaims` is set on the WINNING candidate. Default `reject`.'),
458
+ }).shape,
459
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
460
+ };
461
+ export function makeWorkNextHandler(client) {
462
+ // Per-connection rules-hash cache, same pattern as orboto_work_start.
463
+ let lastKnownRulesHash;
464
+ return async (args, extra) => {
465
+ const token = mcpInstanceToken(args.agentSessionToken, extra);
466
+ const res = await client.post('/work-sessions/next', {
467
+ projectKey: args.projectKey,
468
+ ...(args.agentTag ? { agentTag: args.agentTag } : {}),
469
+ ...(args.role ? { role: args.role } : {}),
470
+ ...(args.leaseSeconds ? { leaseSeconds: args.leaseSeconds } : {}),
471
+ ...(args.startTimer !== undefined ? { startTimer: args.startTimer } : {}),
472
+ ...(args.resourceClaims && args.resourceClaims.length > 0 ? { resourceClaims: args.resourceClaims } : {}),
473
+ ...(args.onConflict ? { onConflict: args.onConflict } : {}),
474
+ agentSessionToken: token,
475
+ ...(lastKnownRulesHash ? { knownRulesHash: lastKnownRulesHash } : {}),
476
+ });
477
+ if (!res.reserved) {
478
+ // ORB-1774 - a pause is an operator decision, not a backoff situation:
479
+ // tell the agent to idle, not to poll for a free slot.
480
+ if (res.reason === 'autonomy_paused') {
481
+ return {
482
+ content: [{
483
+ type: 'text',
484
+ text: [
485
+ 'Autonomous work is PAUSED for this agent (by an operator, per-bot or workspace-wide). Do not poll for new tickets - idle and wait for an operator instruction or an agent-notify wakeup. Explicitly assigned work via orboto_work_start still runs.',
486
+ ...landedIdleLines(res.landedIdle),
487
+ ].join('\n'),
488
+ }],
489
+ structuredContent: {
490
+ reserved: null,
491
+ reason: res.reason,
492
+ retryAfterSeconds: null,
493
+ earliestFreeAt: null,
494
+ candidatesConsidered: 0,
495
+ ...(res.landedIdle && res.landedIdle.length > 0 ? { landedIdle: res.landedIdle } : {}),
496
+ },
497
+ };
498
+ }
499
+ const lines = [`No ready ticket in "${args.projectKey}" right now (${res.reason}).`];
500
+ lines.push(res.retryAfterSeconds != null
501
+ ? `Retry in ~${res.retryAfterSeconds}s (earliest known free: ${res.earliestFreeAt}).`
502
+ : 'No derivable ETA - poll again later or check the project board.');
503
+ lines.push(`Candidates considered: ${res.candidatesConsidered}.`);
504
+ lines.push(...landedIdleLines(res.landedIdle));
505
+ return {
506
+ content: [{ type: 'text', text: lines.join('\n') }],
507
+ structuredContent: {
508
+ reserved: null,
509
+ reason: res.reason,
510
+ retryAfterSeconds: res.retryAfterSeconds,
511
+ earliestFreeAt: res.earliestFreeAt,
512
+ candidatesConsidered: res.candidatesConsidered,
513
+ ...(res.landedIdle && res.landedIdle.length > 0 ? { landedIdle: res.landedIdle } : {}),
514
+ },
515
+ };
516
+ }
517
+ const r = res.reserved;
518
+ if (r.rulesHash)
519
+ lastKnownRulesHash = r.rulesHash;
520
+ const lines = [
521
+ r.reused
522
+ ? `Renewed your existing ${r.session.role} session on ${r.ticket.ticketKey ?? r.session.ticketId}.`
523
+ : `Reserved ${r.ticket.ticketKey ?? r.session.ticketId} - started a ${r.session.role} work session.`,
524
+ `Session id: ${r.session.id} (pass this to orboto_work_session_finish / orboto_work_finish).`,
525
+ `Lease held until ${r.session.leaseUntil}; it renews automatically while you keep calling orboto.`,
526
+ r.session.activeTimerId ? 'Timer running.' : 'No timer started for this role.',
527
+ ];
528
+ if (r.queued && r.queued.length > 0) {
529
+ lines.push('Queued (waiting for a conflicting writer to release):', ...r.queued.map((q) => ` - ${q.claim.kind}:${q.claim.value} - position ${q.position}`));
530
+ }
531
+ lines.push('', '## Working rules');
532
+ if (r.rulesUnchanged) {
533
+ lines.push(`Unchanged since your last call on this connection (hash ${r.rulesHash}) - keep following what you already loaded.`);
534
+ }
535
+ else {
536
+ lines.push(r.rules?.trim() || '(no workspace rules configured)');
537
+ }
538
+ lines.push('', '## Project primer');
539
+ lines.push(r.primer.markdown.trim() || '(primer unavailable)');
540
+ lines.push('', `## Ticket: ${r.ticket.ticketKey ?? '?'}`);
541
+ lines.push(r.ticket.title);
542
+ lines.push(`Status: ${r.ticket.statusName ?? r.ticket.status} Priority: ${r.ticket.priority} Type: ${r.ticket.type}`);
543
+ if (r.ticket.description)
544
+ lines.push('', r.ticket.description);
545
+ lines.push('', '## Checklists');
546
+ if (r.checklists.length === 0) {
547
+ lines.push('(none)');
548
+ }
549
+ else {
550
+ for (const cl of r.checklists) {
551
+ lines.push(`${cl.title} (${cl.progress.done}/${cl.progress.total})${cl.triggersDone ? ' - triggers done' : ''}`);
552
+ for (const i of cl.items) {
553
+ const link = i.linkedTicketKey ? ` -> [${i.linkedTicketKey}] (${i.linkedTicketStatusCategory ?? 'unknown'})` : '';
554
+ lines.push(`- [${i.effectiveCompleted ? 'x' : ' '}] ${i.content}${link}`);
555
+ }
556
+ }
557
+ }
558
+ // ORB-1614 - see the comment on the other fmtDeps above.
559
+ 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');
560
+ lines.push('', '## Dependencies');
561
+ lines.push('Blocked by:', fmtDeps(r.dependencies.blockedBy), 'Blocks:', fmtDeps(r.dependencies.blocks));
562
+ const unhealthy = r.gitHealth.filter((c) => !c.healthy);
563
+ if (unhealthy.length > 0) {
564
+ lines.push('', '## Git connection health - WARNING');
565
+ for (const c of unhealthy) {
566
+ lines.push(`- "${c.name}" (${c.provider}) - ${GIT_HEALTH_REASON_TEXT[c.reason ?? ''] ?? c.reason ?? 'unknown reason'}`);
567
+ }
568
+ }
569
+ lines.push('', '## Other sessions on this ticket');
570
+ lines.push(r.siblingSessions.length === 0
571
+ ? '(none)'
572
+ : r.siblingSessions.map((s) => ` - [${s.role}] ${s.userFullName ?? s.userEmail ?? 'unknown'} - lease until ${s.leaseUntil}`).join('\n'));
573
+ lines.push(...landedIdleLines(res.landedIdle));
574
+ return {
575
+ content: [{ type: 'text', text: lines.join('\n') }],
576
+ structuredContent: {
577
+ reserved: {
578
+ session: r.session,
579
+ reused: r.reused,
580
+ queued: r.queued ?? [],
581
+ rulesHash: r.rulesHash,
582
+ rulesUnchanged: r.rulesUnchanged,
583
+ primer: r.primer,
584
+ ticket: r.ticket,
585
+ checklists: r.checklists,
586
+ dependencies: r.dependencies,
587
+ gitHealth: r.gitHealth,
588
+ siblingSessions: r.siblingSessions,
589
+ },
590
+ reason: null,
591
+ retryAfterSeconds: null,
592
+ earliestFreeAt: null,
593
+ candidatesConsidered: res.candidatesConsidered,
594
+ ...(res.landedIdle && res.landedIdle.length > 0 ? { landedIdle: res.landedIdle } : {}),
595
+ },
596
+ };
597
+ };
598
+ }
599
+ // ---------------------------------------------------------------------------
600
+ // orboto_work_sessions
601
+ // ---------------------------------------------------------------------------
602
+ export const workSessionsToolConfig = {
603
+ title: 'List live work sessions (who is working on what)',
604
+ description: 'The coordination view: every live work lease you can see, or the sessions on one ticket. Call this BEFORE picking up work in a fleet - a ticket with a live `implementation` lease is already being worked, and starting on it anyway is how two agents produce conflicting commits. Unlike agent presence (which reports who is online), this reports who OWNS what, across accounts.',
605
+ inputSchema: z.object({
606
+ ticketKey: z.string().optional().describe('Scope to one ticket. Omit to list every live session you can see.'),
607
+ scope: z.enum(['mine', 'all']).optional().describe('`mine` = only my own sessions. Default `all`. Ignored when ticketKey is set.'),
608
+ includeClosed: z.boolean().optional().describe('With ticketKey: also show finished/expired sessions (the ticket\'s work history).'),
609
+ }).shape,
610
+ annotations: { readOnlyHint: true, idempotentHint: true },
611
+ };
612
+ export function makeWorkSessionsHandler(client) {
613
+ return async (args) => {
614
+ let rows;
615
+ let heading;
616
+ if (args.ticketKey) {
617
+ const ticket = await resolveTicketByKey(client, args.ticketKey);
618
+ const qs = args.includeClosed ? '?includeClosed=true' : '';
619
+ rows = await client.get(`/tickets/${ticket.id}/work-sessions${qs}`);
620
+ heading = `Work sessions on ${args.ticketKey}`;
621
+ }
622
+ else if (args.scope === 'mine') {
623
+ rows = await client.get('/work-sessions/mine');
624
+ heading = 'My live work sessions';
625
+ }
626
+ else {
627
+ rows = await client.get('/work-sessions/active');
628
+ heading = 'Live work sessions';
629
+ }
630
+ const text = rows.length === 0
631
+ ? `${heading}: none.`
632
+ : [`${heading} (${rows.length}):`, ...rows.map(describe)].join('\n');
633
+ return { content: [{ type: 'text', text }], structuredContent: { sessions: rows } };
634
+ };
635
+ }
636
+ // ---------------------------------------------------------------------------
637
+ // orboto_work_session_claims_add
638
+ // ---------------------------------------------------------------------------
639
+ export const workSessionClaimsAddToolConfig = {
640
+ title: 'Add resource claims to a live work session',
641
+ description: 'ORB-1610 - declare more resource claims on a session you already hold (from orboto_work_session_start), without touching the ticket lease or timer. Use this when you did not know the files/resources you would touch at start time, or need to widen scope mid-task. Same conflict rule as start: `write` claims conflict with any OVERLAPPING active write claim workspace-wide; `read` claims never conflict. Default `onConflict: "reject"` fails the WHOLE call (nothing is added) and names every conflicting holder; `queue` accepts the conflicting ones as `state: "waiting"`.',
642
+ inputSchema: z.object({
643
+ sessionId: z.string().uuid().describe('The id returned by orboto_work_session_start.'),
644
+ claims: z.array(z.object(ResourceClaimShape)).min(1).max(50)
645
+ .describe('Claims to add. `kind: "path"` = glob relative to the repo root. `kind: "named"` = exact-match exclusive resource id.'),
646
+ onConflict: z.enum(['reject', 'queue']).optional().describe('Default `reject`.'),
647
+ }).shape,
648
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
649
+ };
650
+ export function makeWorkSessionClaimsAddHandler(client) {
651
+ return async (args) => {
652
+ try {
653
+ const res = await client.post(`/work-sessions/${args.sessionId}/claims`, { claims: args.claims, ...(args.onConflict ? { onConflict: args.onConflict } : {}) });
654
+ const granted = (res.session.resourceClaims ?? []).filter((c) => c.state !== 'waiting');
655
+ const lines = [`Session ${args.sessionId} now holds ${granted.length} granted claim(s).`];
656
+ if (res.queued.length > 0) {
657
+ lines.push('Queued (waiting for a conflicting writer to release):', ...res.queued.map((q) => ` - ${q.claim.kind}:${q.claim.value} - position ${q.position}`));
658
+ }
659
+ return {
660
+ content: [{ type: 'text', text: lines.join('\n') }],
661
+ structuredContent: { session: res.session, queued: res.queued },
662
+ };
663
+ }
664
+ catch (err) {
665
+ if (err instanceof OrbotoApiError && err.status === 409) {
666
+ const claimConflicts = parseClaimConflicts(err);
667
+ if (claimConflicts && claimConflicts.length > 0) {
668
+ return {
669
+ content: [{
670
+ type: 'text',
671
+ text: `No claims were added to session ${args.sessionId} - one or more conflict with an active writer:\n` +
672
+ `${describeClaimConflicts(claimConflicts)}\n` +
673
+ 'Re-run with onConflict="queue" to wait instead of failing, or narrow the glob.',
674
+ }],
675
+ structuredContent: { claimConflict: true, claimConflicts },
676
+ isError: true,
677
+ };
678
+ }
679
+ }
680
+ throw err;
681
+ }
682
+ };
683
+ }
684
+ // ---------------------------------------------------------------------------
685
+ // orboto_work_session_claims_release
686
+ // ---------------------------------------------------------------------------
687
+ export const workSessionClaimsReleaseToolConfig = {
688
+ title: 'Release resource claims from a live work session',
689
+ description: 'ORB-1610 - drop specific resource claims (or all of them) from a session you hold, WITHOUT finishing the session or touching the ticket lease/timer. Releasing a granted write claim immediately runs a grant pass, so the earliest queued waiter for that resource is promoted as part of this call - useful once you know you are done touching a subtree but are not done with the ticket. Omit `claims` to release everything the session holds.',
690
+ inputSchema: z.object({
691
+ sessionId: z.string().uuid().describe('The id returned by orboto_work_session_start.'),
692
+ claims: z.array(z.object({ kind: z.enum(['path', 'named']), value: z.string().min(1).max(500) })).optional()
693
+ .describe('Which claims to release, matched by kind+value. Omit to release ALL claims on this session.'),
694
+ }).shape,
695
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
696
+ };
697
+ export function makeWorkSessionClaimsReleaseHandler(client) {
698
+ return async (args) => {
699
+ const session = await client.delete(`/work-sessions/${args.sessionId}/claims`, { ...(args.claims && args.claims.length > 0 ? { claims: args.claims } : {}) });
700
+ const remaining = session.resourceClaims ?? [];
701
+ const text = args.claims && args.claims.length > 0
702
+ ? `Released ${args.claims.length} claim(s) from session ${args.sessionId}. ${remaining.length} claim(s) remain.`
703
+ : `Released every claim on session ${args.sessionId}.`;
704
+ return {
705
+ content: [{ type: 'text', text }],
706
+ structuredContent: { session },
707
+ };
708
+ };
709
+ }