@orboto/mcp 0.181.0 → 0.182.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.
@@ -50,6 +50,8 @@
50
50
  import { z } from 'zod';
51
51
  import { resolveTicketByKey, applyAgentProfile } from './shared.js';
52
52
  import { PROTECT_TEXT_META, storePayload } from '../response-budget.js';
53
+ import { loadRequiredRules } from '../required-rules.js';
54
+ import { GIT_HEALTH_REASON_TEXT } from './git-health-reasons.js';
53
55
  export const sessionStartToolConfig = {
54
56
  title: 'Load the rules you must follow + re-orient',
55
57
  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. '
@@ -72,18 +74,6 @@ export const sessionStartToolConfig = {
72
74
  }).shape,
73
75
  annotations: { readOnlyHint: true, idempotentHint: true },
74
76
  };
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
77
  // Cap how many distinct projects we probe for git health - a session's
88
78
  // in-progress work is capped at 20 tickets already, so this rarely
89
79
  // exceeds a handful, but bound it defensively so a pathological account
@@ -241,10 +231,10 @@ export function makeSessionStartHandler(client) {
241
231
  // rules from the API instead, costs one call, and skips the whole rest
242
232
  // of the digest. Protected in both halves: it IS the rule text.
243
233
  if (input.rulesOnly) {
244
- const rules = await client.get(rulesPath).catch(() => ({}));
234
+ const rules = await loadRequiredRules(client, rulesPath);
245
235
  if (rules.rulesHash)
246
236
  lastKnownRulesHash = rules.rulesHash;
247
- const text = rules.instructions?.trim() ?? '';
237
+ const text = rules.instructions ?? '';
248
238
  return {
249
239
  _meta: { [PROTECT_TEXT_META]: true },
250
240
  content: [{
@@ -267,7 +257,7 @@ export function makeSessionStartHandler(client) {
267
257
  }
268
258
  const [me, rules, assigned, timer, inboxRaw] = await Promise.all([
269
259
  client.get('/users/me').catch(() => null),
270
- client.get(rulesPath).catch(() => ({})),
260
+ loadRequiredRules(client, rulesPath, rulesParams.get('knownRulesHash') ?? undefined),
271
261
  // ORB-1330 - a re-orientation briefing must only list OPEN work.
272
262
  // Filter to in_progress + in_review so DONE tickets can't pose as
273
263
  // "what you're working on" at the moment the agent has the least
@@ -282,8 +272,6 @@ export function makeSessionStartHandler(client) {
282
272
  .catch(() => []),
283
273
  ]);
284
274
  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
275
  const tickets = Array.isArray(assigned) ? assigned : (assigned?.items ?? []);
288
276
  // ORB-1605 - git-connection health for every project the caller has
289
277
  // open work in right now. Cheap, computed, read-only (see
@@ -326,7 +314,7 @@ export function makeSessionStartHandler(client) {
326
314
  // index - one line per rule + the hash + a handle; the default.
327
315
  // full - the text inline (forceRules, or an API too old to send
328
316
  // an index - never leave a caller without its rules).
329
- const rulesText = rules.instructions?.trim() ?? '';
317
+ const rulesText = rules.instructions ?? '';
330
318
  const rulesIndex = Array.isArray(rules.rulesIndex)
331
319
  ? rules.rulesIndex.map((e) => e?.title).filter((t) => typeof t === 'string' && t.length > 0)
332
320
  : [];
@@ -391,7 +379,7 @@ export function makeSessionStartHandler(client) {
391
379
  if (bundle)
392
380
  lines.push(...bundle.lines);
393
381
  lines.push('', 'Re-run this after any context compaction to re-sync.');
394
- return {
382
+ const result = {
395
383
  // ORB-1818 - when the full rule text IS the payload, neither half
396
384
  // may be cut; see PROTECT_TEXT_META. The index answer carries no
397
385
  // rule text and is budgeted like any other response.
@@ -431,5 +419,7 @@ export function makeSessionStartHandler(client) {
431
419
  ...(bundle ? { ticketBundle: bundle.structured } : {}),
432
420
  },
433
421
  };
422
+ lastKnownRulesHash = rules.rulesHash;
423
+ return result;
434
424
  };
435
425
  }
@@ -44,7 +44,7 @@ describe('orboto_session_start (ORB-1093)', () => {
44
44
  const calls = stubByPath({
45
45
  '/users/me/assigned-tickets': { items: [{ ticketKey: 'ORB-42', title: 'Wire it up', statusName: 'In Progress' }] },
46
46
  '/users/me': { email: 'dev@x.io', fullName: 'Dev', workspaceLocale: 'en' },
47
- '/agent-instructions': { instructions: 'claim -> commit -> close' },
47
+ '/agent-instructions': { instructions: 'claim -> commit -> close', rulesHash: 'fixture' },
48
48
  '/time/timer': { ticketId: 't1', ticketKey: 'ORB-42', startedAt: '2026-06-15T10:00:00Z' },
49
49
  });
50
50
  const res = await makeSessionStartHandler(client)();
@@ -65,7 +65,7 @@ describe('orboto_session_start (ORB-1093)', () => {
65
65
  stubByPath({
66
66
  '/users/me/assigned-tickets': { items: [] },
67
67
  '/users/me': { email: 'dev@x.io', fullName: 'Dev' },
68
- '/agent-instructions': { instructions: 'rules here' },
68
+ '/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
69
69
  '/time/timer': {},
70
70
  });
71
71
  const res = await makeSessionStartHandler(client)();
@@ -82,7 +82,7 @@ describe('orboto_session_start (ORB-1093)', () => {
82
82
  items: [{ ticketKey: 'ORB-42', title: 'Wire it up', statusName: 'In Review', projectId: 'proj-1' }],
83
83
  },
84
84
  '/users/me': { email: 'dev@x.io', fullName: 'Dev' },
85
- '/agent-instructions': { instructions: 'rules here' },
85
+ '/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
86
86
  '/time/timer': {},
87
87
  '/projects/proj-1/git-health': {
88
88
  connections: [{
@@ -109,7 +109,7 @@ describe('orboto_session_start (ORB-1093)', () => {
109
109
  items: [{ ticketKey: 'ORB-42', title: 'Wire it up', statusName: 'In Review', projectId: 'proj-1' }],
110
110
  },
111
111
  '/users/me': { email: 'dev@x.io', fullName: 'Dev' },
112
- '/agent-instructions': { instructions: 'rules here' },
112
+ '/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
113
113
  '/time/timer': {},
114
114
  '/projects/proj-1/git-health': {
115
115
  connections: [{
@@ -298,7 +298,7 @@ describe('orboto_session_start - context cost (ORB-1697)', () => {
298
298
  items: [{ ticketKey: 'ORB-42', title: 'Wire it up', statusName: 'In Review', projectId: 'proj-1' }],
299
299
  },
300
300
  '/users/me': { email: 'dev@x.io', fullName: 'Dev' },
301
- '/agent-instructions': { instructions: 'rules here' },
301
+ '/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
302
302
  '/time/timer': {},
303
303
  '/projects/proj-1/git-health': {
304
304
  connections: [{
@@ -332,7 +332,7 @@ describe('orboto_session_start - context cost (ORB-1697)', () => {
332
332
  ],
333
333
  },
334
334
  '/users/me': { email: 'dev@x.io', fullName: 'Dev' },
335
- '/agent-instructions': { instructions: 'rules here' },
335
+ '/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
336
336
  '/time/timer': {},
337
337
  '/v1/agent/presence': [],
338
338
  });
@@ -355,7 +355,7 @@ describe('orboto_session_start - context cost (ORB-1697)', () => {
355
355
  ],
356
356
  },
357
357
  '/users/me': { email: 'dev@x.io', fullName: 'Dev' },
358
- '/agent-instructions': { instructions: 'rules here' },
358
+ '/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
359
359
  '/time/timer': {},
360
360
  });
361
361
  const res = await makeSessionStartHandler(client)();
@@ -383,7 +383,7 @@ describe('orboto_session_start - a 200 with an unexpected body never kills the d
383
383
  '/projects/proj-1/tickets/by-key/42': { id: 'tick-1', projectId: 'proj-1', ticketKey: 'ORB-42', title: 'Bug' },
384
384
  '/users/me/assigned-tickets': { items: [] },
385
385
  '/users/me': { email: 'dev@x.io', fullName: 'Dev' },
386
- '/agent-instructions': { instructions: 'rules here' },
386
+ '/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
387
387
  });
388
388
  const res = await makeSessionStartHandler(client)({ ticketKey: 'ORB-42' });
389
389
  const text = res.content[0].text;
@@ -428,19 +428,20 @@ export function makeDeleteTicketHandler(client) {
428
428
  };
429
429
  }
430
430
  export const commentToolConfig = {
431
- title: 'Post a comment on a ticket',
432
- description: 'Append a comment. Supports Markdown. `isInternal=true` hides the comment from external/guest users (use for implementation chatter the customer shouldn\'t see).',
431
+ title: 'Post a comment',
432
+ description: 'Post Markdown. isInternal=true hides it from guests. Attachment drafts are claimed atomically with the comment; never use public ticket uploads for private comments.',
433
433
  inputSchema: z.object({
434
434
  ticketKey: z.string().min(3),
435
435
  text: z.string().min(1),
436
- isInternal: z.boolean().optional().describe('Default: false (visible to all members + guests).'),
436
+ isInternal: z.boolean().optional().describe('Default false: guests can read.'),
437
+ attachmentDraftIds: z.array(z.string().uuid()).max(20).optional().describe('Private /attachment-drafts/tickets/:ticketId uploads only.'),
437
438
  }).shape,
438
439
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
439
440
  };
440
441
  export function makeCommentHandler(client) {
441
- return async ({ ticketKey, text, isInternal }) => {
442
+ return async ({ ticketKey, text, isInternal, attachmentDraftIds }) => {
442
443
  const ticket = await resolveTicketByKey(client, ticketKey);
443
- const created = await client.post(`/tickets/${ticket.id}/comments`, { content: text, isInternal: isInternal ?? false });
444
+ const created = await client.post(`/tickets/${ticket.id}/comments`, { content: text, isInternal: isInternal ?? false, ...(attachmentDraftIds ? { attachmentDraftIds } : {}) });
444
445
  return {
445
446
  content: [{
446
447
  type: 'text',
@@ -395,6 +395,12 @@ describe('orboto_delete_ticket', () => {
395
395
  });
396
396
  });
397
397
  describe('orboto_comment', () => {
398
+ it('forwards private upload claims without dropping the internal flag', async () => {
399
+ const calls = stub([{ json: PROJ }, { json: TICKET }, { json: { id: 'c1', content: 'private image', isInternal: true, createdAt: 'now' } }]);
400
+ const ids = ['aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa'];
401
+ await makeCommentHandler(client)({ ticketKey: 'ACME-1', text: 'private image', isInternal: true, attachmentDraftIds: ids });
402
+ expect(calls[2].body).toEqual({ content: 'private image', isInternal: true, attachmentDraftIds: ids });
403
+ });
398
404
  it('posts a regular (non-internal) comment by default', async () => {
399
405
  const calls = stub([
400
406
  { json: PROJ },
@@ -0,0 +1,208 @@
1
+ /**
2
+ * ORB-1613 - `orboto_work_next`, the pull side of low-management dispatch
3
+ * (Wave 3 of ORB-1602), in its own module since ORB-1930 added the peek.
4
+ * The shapes it shares with `orboto_work_start` live in
5
+ * work-sessions-shared.ts.
6
+ */
7
+ import { z } from 'zod';
8
+ import { mcpInstanceToken } from './shared.js';
9
+ import { validateNextWorkEnvelope, validateRulesReceipt } from '../required-rules.js';
10
+ import { GIT_HEALTH_REASON_TEXT, ResourceClaimShape } from './work-sessions-shared.js';
11
+ /** ORB-1799 - rendered identically on every exit (reserved, empty, paused):
12
+ * an agent that just pulled fresh work is exactly the one about to forget
13
+ * the ticket it already finished. Empty array renders nothing. */
14
+ function landedIdleLines(rows) {
15
+ if (!rows || rows.length === 0)
16
+ return [];
17
+ return [
18
+ '',
19
+ '## Landed, idle - finished-looking work of yours awaiting the status move',
20
+ ...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}).`),
21
+ '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.',
22
+ ];
23
+ }
24
+ export const workNextToolConfig = {
25
+ title: 'Pull and reserve the next ready ticket (worker-pool dispatch)',
26
+ 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.',
27
+ inputSchema: z.object({
28
+ projectKey: z.string().min(1).describe('Project key (e.g. "ACME") or UUID.'),
29
+ agentTag: z.string().min(1).max(64).optional()
30
+ .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).'),
31
+ role: z.enum(['implementation', 'review', 'preflight', 'integration']).optional()
32
+ .describe('Default `implementation`. The dispatcher only reserves a ticket whose (ticket, role) lease is free for THIS role.'),
33
+ leaseSeconds: z.number().int().min(60).max(86_400).optional()
34
+ .describe('How long the lease should hold without renewal. Default 900 (15 min).'),
35
+ startTimer: z.boolean().optional()
36
+ .describe('Defaults to true for `implementation`, false for the attach-only roles.'),
37
+ agentSessionToken: z.string().optional()
38
+ .describe('Stable per-agent-instance token. Omit to use this MCP connection\'s own instance id.'),
39
+ resourceClaims: z.array(z.object(ResourceClaimShape)).max(50).optional()
40
+ .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.'),
41
+ onConflict: z.enum(['reject', 'queue']).optional()
42
+ .describe('Only matters when `resourceClaims` is set on the WINNING candidate. Default `reject`.'),
43
+ peek: z.boolean().optional()
44
+ .describe('ORB-1930 - answer "is there work for me?" WITHOUT reserving anything: the same candidate walk (role pool, lane caps, labels, dependencies, leases), but no lease, no timer, no lane quota consumed. Returns `candidate` (the ticket a real pull would reserve right now) or the usual empty reason. Use it before spending a model turn on self-tasking.'),
45
+ }).shape,
46
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
47
+ };
48
+ export function makeWorkNextHandler(client) {
49
+ // Per-connection rules-hash cache, same pattern as orboto_work_start.
50
+ let lastKnownRulesHash;
51
+ return async (args, extra) => {
52
+ const token = mcpInstanceToken(args.agentSessionToken, extra);
53
+ const res = await client.post('/work-sessions/next', {
54
+ projectKey: args.projectKey,
55
+ ...(args.peek ? { peek: true } : {}),
56
+ ...(args.agentTag ? { agentTag: args.agentTag } : {}),
57
+ ...(args.role ? { role: args.role } : {}),
58
+ ...(args.leaseSeconds ? { leaseSeconds: args.leaseSeconds } : {}),
59
+ ...(args.startTimer !== undefined ? { startTimer: args.startTimer } : {}),
60
+ ...(args.resourceClaims && args.resourceClaims.length > 0 ? { resourceClaims: args.resourceClaims } : {}),
61
+ ...(args.onConflict ? { onConflict: args.onConflict } : {}),
62
+ agentSessionToken: token,
63
+ ...(lastKnownRulesHash ? { knownRulesHash: lastKnownRulesHash } : {}),
64
+ });
65
+ validateNextWorkEnvelope(res, args.peek);
66
+ if (!res.reserved) {
67
+ // ORB-1930 - a peek that found work: name it, reserve nothing.
68
+ if (args.peek && res.candidate) {
69
+ const c = res.candidate;
70
+ return {
71
+ content: [{
72
+ type: 'text',
73
+ text: [
74
+ `Work is ready in "${args.projectKey}": ${c.ticketKey ?? c.ticketId} - ${c.title}${c.priority ? ` (${c.priority})` : ''}.`,
75
+ 'Nothing was reserved (peek). Call orboto_work_next without `peek` to take it.',
76
+ ...landedIdleLines(res.landedIdle),
77
+ ].join('\n'),
78
+ }],
79
+ structuredContent: {
80
+ reserved: null,
81
+ reason: null,
82
+ candidate: c,
83
+ candidatesConsidered: res.candidatesConsidered,
84
+ ...(res.landedIdle && res.landedIdle.length > 0 ? { landedIdle: res.landedIdle } : {}),
85
+ },
86
+ };
87
+ }
88
+ // ORB-1774 - a pause is an operator decision, not a backoff situation:
89
+ // tell the agent to idle, not to poll for a free slot.
90
+ if (res.reason === 'autonomy_paused') {
91
+ return {
92
+ content: [{
93
+ type: 'text',
94
+ text: [
95
+ '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.',
96
+ ...landedIdleLines(res.landedIdle),
97
+ ].join('\n'),
98
+ }],
99
+ structuredContent: {
100
+ reserved: null,
101
+ reason: res.reason,
102
+ retryAfterSeconds: null,
103
+ earliestFreeAt: null,
104
+ candidatesConsidered: 0,
105
+ ...(res.landedIdle && res.landedIdle.length > 0 ? { landedIdle: res.landedIdle } : {}),
106
+ },
107
+ };
108
+ }
109
+ const lines = [`No ready ticket in "${args.projectKey}" right now (${res.reason}).`];
110
+ lines.push(res.retryAfterSeconds != null
111
+ ? `Retry in ~${res.retryAfterSeconds}s (earliest known free: ${res.earliestFreeAt}).`
112
+ : 'No derivable ETA - poll again later or check the project board.');
113
+ lines.push(`Candidates considered: ${res.candidatesConsidered}.`);
114
+ lines.push(...landedIdleLines(res.landedIdle));
115
+ return {
116
+ content: [{ type: 'text', text: lines.join('\n') }],
117
+ structuredContent: {
118
+ reserved: null,
119
+ reason: res.reason,
120
+ retryAfterSeconds: res.retryAfterSeconds,
121
+ earliestFreeAt: res.earliestFreeAt,
122
+ candidatesConsidered: res.candidatesConsidered,
123
+ ...(res.landedIdle && res.landedIdle.length > 0 ? { landedIdle: res.landedIdle } : {}),
124
+ },
125
+ };
126
+ }
127
+ const r = res.reserved;
128
+ validateRulesReceipt(r, lastKnownRulesHash, 'rules');
129
+ const lines = [
130
+ r.reused
131
+ ? `Renewed your existing ${r.session.role} session on ${r.ticket.ticketKey ?? r.session.ticketId}.`
132
+ : `Reserved ${r.ticket.ticketKey ?? r.session.ticketId} - started a ${r.session.role} work session.`,
133
+ `Session id: ${r.session.id} (pass this to orboto_work_session_finish / orboto_work_finish).`,
134
+ `Lease held until ${r.session.leaseUntil}; it renews automatically while you keep calling orboto.`,
135
+ r.session.activeTimerId ? 'Timer running.' : 'No timer started for this role.',
136
+ ];
137
+ if (r.queued && r.queued.length > 0) {
138
+ lines.push('Queued (waiting for a conflicting writer to release):', ...r.queued.map((q) => ` - ${q.claim.kind}:${q.claim.value} - position ${q.position}`));
139
+ }
140
+ lines.push('', '## Working rules');
141
+ if (r.rulesUnchanged) {
142
+ lines.push(`Unchanged since your last call on this connection (hash ${r.rulesHash}). If these rules are not currently in your context, call orboto_session_start with rulesOnly: true before proceeding.`);
143
+ }
144
+ else {
145
+ lines.push(r.rules?.trim() || '(no workspace rules configured)');
146
+ }
147
+ lines.push('', '## Project primer');
148
+ lines.push(r.primer.markdown.trim() || '(primer unavailable)');
149
+ lines.push('', `## Ticket: ${r.ticket.ticketKey ?? '?'}`);
150
+ lines.push(r.ticket.title);
151
+ lines.push(`Status: ${r.ticket.statusName ?? r.ticket.status} Priority: ${r.ticket.priority} Type: ${r.ticket.type}`);
152
+ if (r.ticket.description)
153
+ lines.push('', r.ticket.description);
154
+ lines.push('', '## Checklists');
155
+ if (r.checklists.length === 0) {
156
+ lines.push('(none)');
157
+ }
158
+ else {
159
+ for (const cl of r.checklists) {
160
+ lines.push(`${cl.title} (${cl.progress.done}/${cl.progress.total})${cl.triggersDone ? ' - triggers done' : ''}`);
161
+ for (const i of cl.items) {
162
+ const link = i.linkedTicketKey ? ` -> [${i.linkedTicketKey}] (${i.linkedTicketStatusCategory ?? 'unknown'})` : '';
163
+ lines.push(`- [${i.effectiveCompleted ? 'x' : ' '}] ${i.content}${link}`);
164
+ }
165
+ }
166
+ }
167
+ // ORB-1614 - see the comment on the other fmtDeps above.
168
+ 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');
169
+ lines.push('', '## Dependencies');
170
+ lines.push('Blocked by:', fmtDeps(r.dependencies.blockedBy), 'Blocks:', fmtDeps(r.dependencies.blocks));
171
+ const unhealthy = r.gitHealth.filter((c) => !c.healthy);
172
+ if (unhealthy.length > 0) {
173
+ lines.push('', '## Git connection health - WARNING');
174
+ for (const c of unhealthy) {
175
+ lines.push(`- "${c.name}" (${c.provider}) - ${GIT_HEALTH_REASON_TEXT[c.reason ?? ''] ?? c.reason ?? 'unknown reason'}`);
176
+ }
177
+ }
178
+ lines.push('', '## Other sessions on this ticket');
179
+ lines.push(r.siblingSessions.length === 0
180
+ ? '(none)'
181
+ : r.siblingSessions.map((s) => ` - [${s.role}] ${s.userFullName ?? s.userEmail ?? 'unknown'} - lease until ${s.leaseUntil}`).join('\n'));
182
+ lines.push(...landedIdleLines(res.landedIdle));
183
+ lastKnownRulesHash = r.rulesHash;
184
+ return {
185
+ content: [{ type: 'text', text: lines.join('\n') }],
186
+ structuredContent: {
187
+ reserved: {
188
+ session: r.session,
189
+ reused: r.reused,
190
+ queued: r.queued ?? [],
191
+ rulesHash: r.rulesHash,
192
+ rulesUnchanged: r.rulesUnchanged,
193
+ primer: r.primer,
194
+ ticket: r.ticket,
195
+ checklists: r.checklists,
196
+ dependencies: r.dependencies,
197
+ gitHealth: r.gitHealth,
198
+ siblingSessions: r.siblingSessions,
199
+ },
200
+ reason: null,
201
+ retryAfterSeconds: null,
202
+ earliestFreeAt: null,
203
+ candidatesConsidered: res.candidatesConsidered,
204
+ ...(res.landedIdle && res.landedIdle.length > 0 ? { landedIdle: res.landedIdle } : {}),
205
+ },
206
+ };
207
+ };
208
+ }
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
9
9
  import { OrbotoClient } from '../orboto-client.js';
10
- import { makeWorkNextHandler } from './work-sessions.js';
10
+ import { makeWorkNextHandler } from './work-next.js';
11
11
  beforeEach(() => { vi.restoreAllMocks(); });
12
12
  afterEach(() => { vi.restoreAllMocks(); });
13
13
  function stub(responses) {
@@ -65,8 +65,31 @@ const RESERVED = {
65
65
  siblingSessions: [],
66
66
  };
67
67
  describe('orboto_work_next', () => {
68
+ const peekResponse = {
69
+ reserved: null, reason: null, retryAfterSeconds: null, earliestFreeAt: null,
70
+ candidatesConsidered: 1, landedIdle: [],
71
+ candidate: { ticketId: 't1', ticketKey: 'ACME-42', title: 'Ready', priority: null },
72
+ };
73
+ it('validates a peek candidate without claiming rules delivery or a reservation', async () => {
74
+ const calls = stub([{ json: peekResponse }, { json: peekResponse }]);
75
+ const handler = makeWorkNextHandler(client);
76
+ const result = await handler({ projectKey: 'ACME', peek: true });
77
+ expect(result.structuredContent).toMatchObject({ reserved: null, candidate: peekResponse.candidate });
78
+ expect(calls[0].body).toMatchObject({ peek: true });
79
+ expect(calls[0].body).not.toHaveProperty('knownRulesHash');
80
+ await expect(handler({ projectKey: 'ACME' })).rejects.toMatchObject({ reason: 'invalid_response' });
81
+ });
82
+ it.each([
83
+ { candidate: {} }, { candidate: { ticketId: '', ticketKey: null, title: '', priority: null } },
84
+ { candidate: { ticketId: 't1', title: 'Ready', priority: null } },
85
+ { candidate: { ticketId: 't1', ticketKey: null, title: 42, priority: null } },
86
+ { reason: 'none-matching' }, { reserved: RESERVED }, { candidatesConsidered: -1 },
87
+ ])('rejects malformed or contradictory peek responses %#', async (patch) => {
88
+ stub([{ json: { ...peekResponse, ...patch } }]);
89
+ await expect(makeWorkNextHandler(client)({ projectKey: 'ACME', peek: true })).rejects.toMatchObject({ reason: 'invalid_response' });
90
+ });
68
91
  it('reserves the winning candidate and renders the full bundle, sending projectKey straight through', async () => {
69
- const calls = stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 3 } }]);
92
+ const calls = stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 3, landedIdle: [] } }]);
70
93
  const res = await makeWorkNextHandler(client)({ projectKey: 'ACME' });
71
94
  expect(calls).toHaveLength(1);
72
95
  expect(calls[0].method).toBe('POST');
@@ -87,25 +110,28 @@ describe('orboto_work_next', () => {
87
110
  });
88
111
  it('caches the rules hash across calls and renders "Renewed" on a reused session', async () => {
89
112
  const handler = makeWorkNextHandler(client);
90
- stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1 } }]);
113
+ stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1, landedIdle: [] } }]);
91
114
  await handler({ projectKey: 'ACME' });
92
115
  const calls2 = stub([{
93
116
  json: {
94
117
  reserved: { ...RESERVED, reused: true, rulesUnchanged: true, rules: undefined },
95
- reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1,
118
+ reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1, landedIdle: [],
96
119
  },
97
120
  }]);
98
121
  const res2 = await handler({ projectKey: 'ACME' });
99
122
  expect(calls2[0].body?.knownRulesHash).toBe('abc123');
100
123
  const text2 = res2.content[0].text;
101
124
  expect(text2).toContain('Unchanged since your last call');
125
+ expect(text2).toContain('not currently in your context');
126
+ expect(text2).toContain('rulesOnly: true');
127
+ expect(text2).not.toContain('keep following what you already loaded');
102
128
  expect(text2).toContain('Renewed your existing');
103
129
  });
104
130
  it('renders a structured empty result with a retry hint (all-leased)', async () => {
105
131
  stub([{
106
132
  json: {
107
133
  reserved: null, reason: 'all-leased', retryAfterSeconds: 420,
108
- earliestFreeAt: '2026-07-24T10:15:00Z', candidatesConsidered: 4,
134
+ earliestFreeAt: '2026-07-24T10:15:00Z', candidatesConsidered: 4, landedIdle: [],
109
135
  },
110
136
  }]);
111
137
  const res = await makeWorkNextHandler(client)({ projectKey: 'ACME' });
@@ -124,7 +150,7 @@ describe('orboto_work_next', () => {
124
150
  stub([{
125
151
  json: {
126
152
  reserved: null, reason: 'none-matching', retryAfterSeconds: null,
127
- earliestFreeAt: null, candidatesConsidered: 0,
153
+ earliestFreeAt: null, candidatesConsidered: 0, landedIdle: [],
128
154
  },
129
155
  }]);
130
156
  const res = await makeWorkNextHandler(client)({ projectKey: 'ACME' });
@@ -134,7 +160,7 @@ describe('orboto_work_next', () => {
134
160
  expect(text).not.toContain('Retry in');
135
161
  });
136
162
  it('forwards role, leaseSeconds, and resourceClaims to the API', async () => {
137
- const calls = stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1 } }]);
163
+ const calls = stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1, landedIdle: [] } }]);
138
164
  await makeWorkNextHandler(client)({
139
165
  projectKey: 'ACME',
140
166
  role: 'review',
@@ -0,0 +1,17 @@
1
+ /**
2
+ * ORB-1609 / ORB-1613 - the wire shapes the work-session tools share:
3
+ * resource claims, the start bundle `orboto_work_start` and
4
+ * `orboto_work_next` both return, and the git-health reason texts.
5
+ * Extracted so `orboto_work_next` can live in its own module (file-size
6
+ * ratchet, ORB-1863) without a cycle back into work-sessions.ts.
7
+ */
8
+ import { z } from 'zod';
9
+ export const ResourceClaimShape = {
10
+ kind: z.enum(['path', 'named']),
11
+ value: z.string().min(1).max(500),
12
+ mode: z.enum(['read', 'write']),
13
+ };
14
+ /** Shared 409-body reader: the endpoint's body carries EITHER `holder`
15
+ * (a lease conflict) OR `claimConflicts` (a resource-claim conflict) -
16
+ * never both, since the lease is checked before claims are applied. */
17
+ export { GIT_HEALTH_REASON_TEXT } from './git-health-reasons.js';
@@ -122,6 +122,9 @@ describe('orboto_work_start', () => {
122
122
  expect(post2.body?.knownRulesHash).toBe('abc123');
123
123
  const text2 = res2.content[0].text;
124
124
  expect(text2).toContain('Unchanged since your last work-start');
125
+ expect(text2).toContain('not currently in your context');
126
+ expect(text2).toContain('rulesOnly: true');
127
+ expect(text2).not.toContain('keep following what you already loaded');
125
128
  expect(text2).toContain('Renewed your existing');
126
129
  });
127
130
  it('surfaces the lease holder on a 409 instead of throwing', async () => {