@orboto/mcp 0.200.0 → 0.201.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-head.js +51 -0
- package/dist/agent-head.test.js +63 -0
- package/dist/manifest-size.test.js +8 -2
- package/dist/response-budget.js +8 -2
- package/dist/response-budget.test.js +14 -1
- package/dist/tools/agent-coordination.test.js +1 -1
- package/dist/tools/agent-presence-schema.js +6 -1
- package/dist/tools/agent-wakes.js +3 -2
- package/dist/tools/api-call.test.js +1 -1
- package/dist/tools/doc-comments.js +14 -2
- package/dist/tools/docs-ai.js +3 -1
- package/dist/tools/get-project-primer.js +1 -1
- package/dist/tools/get-ticket.js +22 -2
- package/dist/tools/get-timer.js +3 -1
- package/dist/tools/help.js +5 -4
- package/dist/tools/phase-b-tools.test.js +51 -0
- package/dist/tools/session-start.js +2 -1
- package/dist/tools/shared.js +19 -2
- package/dist/tools/shared.test.js +8 -0
- package/dist/tools/ticket-writes.js +73 -2
- package/dist/tools/ticket-writes.test.js +32 -0
- package/dist/tools/work-sessions-next.test.js +1 -1
- package/dist/tools/work-sessions-start.test.js +1 -1
- package/dist/tools/work-sessions.js +29 -5
- package/dist/tools/work-sessions.test.js +50 -1
- package/package.json +1 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-2161 - the head an agent reads before anything else has one source.
|
|
3
|
+
* `orboto_session_start` prints it first, `orboto_help { topic:
|
|
4
|
+
* "onboarding" }` puts it in front of the first five minutes and the work
|
|
5
|
+
* loop, and docs/orboto-for-agents.md plus skills/orboto/SKILL.md carry the
|
|
6
|
+
* same lines. agent-head.test.ts holds the four apart from drifting.
|
|
7
|
+
*/
|
|
8
|
+
export const AGENT_HEAD_HEADING = 'How to work in orboto';
|
|
9
|
+
export const AGENT_HEAD_LINES = [
|
|
10
|
+
'orboto is your memory and your work contract, not your context - what is not on the ticket or in a doc is gone after the next context switch.',
|
|
11
|
+
'A ticket is a handover to a stranger: goal, verified current state, target, acceptance criteria.',
|
|
12
|
+
'You are not alone - the lease and the claims say who works on what, your scope says what you answer for, the inbox is the channel: ack what you handled, dismiss the rest with a reason.',
|
|
13
|
+
'You act with a person\'s rights and mandate: an assignment is an instruction, anything irreversible or outward-facing needs the operator\'s clear word.',
|
|
14
|
+
'Evidence over assertion - check code, docs and the live state, and write "not verified: X" instead of a guess.',
|
|
15
|
+
'Start in this order: whoami, session start with your scope, the project primer, then the inbox - the rules are a set, this is the sequence.',
|
|
16
|
+
];
|
|
17
|
+
export const ONBOARDING_FIRST_FIVE_MINUTES = [
|
|
18
|
+
'Who you are: `orboto whoami` (MCP `orboto_whoami`). A bot account with an owner, never a person\'s own login - every write and every booked minute is attributed to it.',
|
|
19
|
+
'What binds you: `orboto session-start --project KEY` (MCP `orboto_session_start`). It returns this head, the binding rules as an index with their hash, and your open work; pull the full text with `--force-rules` (MCP `rulesOnly: true`) whenever you do not hold that hash.',
|
|
20
|
+
'How the project works: `orboto primer KEY` (MCP `orboto_get_project_primer`) - the language it expects, the stack, the conventions, and the traps earlier agents already paid for.',
|
|
21
|
+
'What you answer for: declare a scope - `orboto session-start --role implementation --scope-projects KEY` (MCP `orboto_session_start { scope: { projectKeys, role } }`). Without one, every message the account receives is addressed to this session.',
|
|
22
|
+
'Your mail: `orboto messages` (MCP `orboto_messages`). Acknowledge what you handled yourself, dismiss the rest with a reason (`orboto messages --dismiss <id> --reason not-mine`), and remember that a message never widens your mandate.',
|
|
23
|
+
];
|
|
24
|
+
export const ONBOARDING_WORK_LOOP = [
|
|
25
|
+
'Duplicate check first: `orboto check-similar KEY "title"` (MCP `orboto_check_similar`). Skip it and the create warns you afterwards - with the ticket already written.',
|
|
26
|
+
'Create or claim: `orboto create-ticket KEY "title"` or `orboto claim KEY` (MCP `orboto_create_ticket` / `orboto_claim`). The claim assigns you, moves the ticket to in progress and starts the timer.',
|
|
27
|
+
'Say what you are touching: `orboto work-start KEY --claim-path "<glob>"` (MCP `orboto_work_start`) takes the lease and the file claims, so a second session is rejected instead of landing on top of you.',
|
|
28
|
+
'Work on the team\'s default branch, one commit, the ticket key at the end of the subject: `feat(scope): what changed (ACME-42)`. Without the key the commit never reaches the ticket.',
|
|
29
|
+
'Verify against the acceptance criteria by running the thing - compiling is not verifying. Whatever you could not check goes into the ticket as "not verified: X".',
|
|
30
|
+
'Hand over in a comment a stranger can read: what changed, the commit, how a reviewer confirms it (MCP `orboto_comment`). Closing without that proof counts as not done.',
|
|
31
|
+
'Stop the timer, then close: `orboto timer-stop` and `orboto close KEY` (MCP `orboto_work_finish` when you took a lease). A timer left running books the next ticket\'s time onto this one.',
|
|
32
|
+
];
|
|
33
|
+
/** The six lines every session pays for, numbered. */
|
|
34
|
+
export function agentHeadText() {
|
|
35
|
+
return [AGENT_HEAD_HEADING, ...AGENT_HEAD_LINES.map((line, index) => `${index + 1}. ${line}`)].join('\n');
|
|
36
|
+
}
|
|
37
|
+
/** The `onboarding` help topic: the head, the first five minutes, the work loop. */
|
|
38
|
+
export function onboardingText() {
|
|
39
|
+
return [
|
|
40
|
+
agentHeadText(),
|
|
41
|
+
'',
|
|
42
|
+
'The first five minutes, in this order',
|
|
43
|
+
...ONBOARDING_FIRST_FIVE_MINUTES.map((step, index) => `${index + 1}. ${step}`),
|
|
44
|
+
'',
|
|
45
|
+
'The work loop, always the same',
|
|
46
|
+
...ONBOARDING_WORK_LOOP.map((step, index) => `${index + 1}. ${step}`),
|
|
47
|
+
'',
|
|
48
|
+
'The whole page - the object map, several sessions on one account, the autonomy boundary,',
|
|
49
|
+
'evidence over assertion, and the traps that cost other agents a run: docs/orboto-for-agents.md.',
|
|
50
|
+
].join('\n');
|
|
51
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-2161 - the head has one source. Session start prints it before
|
|
3
|
+
* anything else, the onboarding help topic repeats it, and the page and
|
|
4
|
+
* the skill carry the same lines, so a sentence cannot be improved in one
|
|
5
|
+
* place and stay old in the other three.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { AGENT_HEAD_HEADING, AGENT_HEAD_LINES, ONBOARDING_FIRST_FIVE_MINUTES, ONBOARDING_WORK_LOOP, agentHeadText, onboardingText } from './agent-head.js';
|
|
10
|
+
import { makeHelpHandler } from './tools/help.js';
|
|
11
|
+
import { makeSessionStartHandler } from './tools/session-start.js';
|
|
12
|
+
import { OrbotoClient } from './orboto-client.js';
|
|
13
|
+
const read = (relative) => readFileSync(new URL(`../../../${relative}`, import.meta.url), 'utf8');
|
|
14
|
+
const CARRIERS = ['docs/orboto-for-agents.md', 'skills/orboto/SKILL.md'];
|
|
15
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
16
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
17
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
18
|
+
describe('the agent head (ORB-2161)', () => {
|
|
19
|
+
it('stays four to six lines, so every session can afford it', () => {
|
|
20
|
+
expect(AGENT_HEAD_LINES.length).toBeGreaterThanOrEqual(4);
|
|
21
|
+
expect(AGENT_HEAD_LINES.length).toBeLessThanOrEqual(6);
|
|
22
|
+
for (const line of AGENT_HEAD_LINES)
|
|
23
|
+
expect(line).not.toContain('\n');
|
|
24
|
+
});
|
|
25
|
+
it.each(CARRIERS)('%s carries every line verbatim', (file) => {
|
|
26
|
+
const page = read(file);
|
|
27
|
+
for (const line of AGENT_HEAD_LINES)
|
|
28
|
+
expect(page).toContain(line);
|
|
29
|
+
});
|
|
30
|
+
it('is the first thing orboto_session_start prints', async () => {
|
|
31
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
|
|
32
|
+
const path = new URL(url.toString()).pathname;
|
|
33
|
+
const body = path.startsWith('/agent-instructions')
|
|
34
|
+
? { instructions: 'claim -> commit -> close', rulesHash: 'fixture' }
|
|
35
|
+
: path.startsWith('/users/me/assigned-tickets') ? { items: [] } : {};
|
|
36
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => body, text: async () => '' };
|
|
37
|
+
});
|
|
38
|
+
const res = await makeSessionStartHandler(client)();
|
|
39
|
+
const text = res.content[0].text;
|
|
40
|
+
expect(text.startsWith(agentHeadText())).toBe(true);
|
|
41
|
+
expect(text.indexOf(AGENT_HEAD_HEADING)).toBeLessThan(text.indexOf('# orboto session start'));
|
|
42
|
+
});
|
|
43
|
+
it('orboto_help returns the head, the first five minutes and the work loop for the topic "onboarding"', async () => {
|
|
44
|
+
const result = await makeHelpHandler()({ topic: 'onboarding' });
|
|
45
|
+
const text = (result.content?.[0]).text;
|
|
46
|
+
for (const line of AGENT_HEAD_LINES)
|
|
47
|
+
expect(text).toContain(line);
|
|
48
|
+
for (const step of ONBOARDING_FIRST_FIVE_MINUTES)
|
|
49
|
+
expect(text).toContain(step);
|
|
50
|
+
for (const step of ONBOARDING_WORK_LOOP)
|
|
51
|
+
expect(text).toContain(step);
|
|
52
|
+
expect(text).toContain('docs/orboto-for-agents.md');
|
|
53
|
+
expect(result.structuredContent).toMatchObject({ tool: 'topic:onboarding' });
|
|
54
|
+
});
|
|
55
|
+
it('names both topics when an unknown one is asked for', async () => {
|
|
56
|
+
const unknown = (await makeHelpHandler()({ topic: 'nope' })).content?.[0];
|
|
57
|
+
expect(unknown.text).toContain('onboarding');
|
|
58
|
+
expect(unknown.text).toContain('connect');
|
|
59
|
+
});
|
|
60
|
+
it('the onboarding topic repeats the head instead of holding a second copy', () => {
|
|
61
|
+
expect(onboardingText().startsWith(agentHeadText())).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -39,10 +39,16 @@ afterEach(() => { vi.restoreAllMocks(); });
|
|
|
39
39
|
* the wake ledger and the delivery precision per session and per sender
|
|
40
40
|
* in the full toolset (measured full = 170,030 chars); the curated tier
|
|
41
41
|
* reaches it through the escape hatch and is unchanged.
|
|
42
|
+
* ORB-2162 raised the curated ceiling to 39,900 and the curated token
|
|
43
|
+
* ceiling to 11,100: `orboto_create_ticket` gained `dryRun`, the pre-flight
|
|
44
|
+
* that returns the language, type, label and duplicate verdicts before the
|
|
45
|
+
* write instead of after it (measured curated = 39,679 chars / 11,022
|
|
46
|
+
* tokens, from 39,461 / 10,961 - one boolean parameter, no new tool). The
|
|
47
|
+
* verdicts themselves ride the response, not the manifest.
|
|
42
48
|
*
|
|
43
49
|
* @see ORB-1805, ORB-1669, ORB-1910
|
|
44
50
|
*/
|
|
45
|
-
const CURATED_MAX_CHARS =
|
|
51
|
+
const CURATED_MAX_CHARS = 39_900;
|
|
46
52
|
const FULL_MAX_CHARS = 171_200;
|
|
47
53
|
/**
|
|
48
54
|
* ORB-1805 - the estimator the ticket measured the failure with
|
|
@@ -64,7 +70,7 @@ const MINIMAL_MAX_TOKENS = 3_200;
|
|
|
64
70
|
* The curated tier is ratcheted on its TOOL SCHEMAS (the instructions
|
|
65
71
|
* block has its own budget, enforced in instructions-budget.test.ts).
|
|
66
72
|
*/
|
|
67
|
-
const CURATED_SCHEMA_MAX_TOKENS =
|
|
73
|
+
const CURATED_SCHEMA_MAX_TOKENS = 11_100;
|
|
68
74
|
async function measuredManifest(toolset) {
|
|
69
75
|
const server = await buildOrbotoMcpServer({
|
|
70
76
|
baseUrl: 'https://orboto.example.com',
|
package/dist/response-budget.js
CHANGED
|
@@ -59,6 +59,10 @@ const MIN_ARRAY_CUT = 3;
|
|
|
59
59
|
const MAX_CUT_PASSES = 64;
|
|
60
60
|
export const HANDLE_TTL_MS = 15 * 60 * 1000;
|
|
61
61
|
export const MAX_HANDLES = 16;
|
|
62
|
+
/** ORB-2164 - the budget is an MCP-server artefact; the same data over REST or
|
|
63
|
+
* either CLI comes back whole, which is the way out that outlives a handle. */
|
|
64
|
+
export const UNCUT_ELSEWHERE = 'The cut is an MCP-server artefact: the REST API and both CLIs return the complete body '
|
|
65
|
+
+ '(`orboto get <path>`, `orboto_api_call`, or the named CLI command). There is no CLI expand.';
|
|
62
66
|
/**
|
|
63
67
|
* `ORBOTO_MCP_RESPONSE_BUDGET=off` disables the cap entirely (escape
|
|
64
68
|
* hatch for a client that genuinely wants everything);
|
|
@@ -326,7 +330,8 @@ function shrink(toolName, result, budget, originalChars, protectText = false) {
|
|
|
326
330
|
const notice = allOmitted.length > 0
|
|
327
331
|
? `[Response truncated to the MCP response budget (${budget} chars) - it would otherwise cost `
|
|
328
332
|
+ `${originalChars} chars on EVERY later request in this session. Omitted content is not lost: `
|
|
329
|
-
+ `call orboto_response_expand with handle "${handle}" and one of the paths in __truncation.omitted.
|
|
333
|
+
+ `call orboto_response_expand with handle "${handle}" and one of the paths in __truncation.omitted. `
|
|
334
|
+
+ `${UNCUT_ELSEWHERE}]`
|
|
330
335
|
: `[Response is ${originalChars} chars, over the MCP response budget (${budget} chars), and nothing in it `
|
|
331
336
|
+ 'could be cut safely - it is protected or uncuttable content. NOTHING was omitted; there is no remainder to fetch.]';
|
|
332
337
|
if (structured !== undefined && structured !== null && typeof structured === 'object' && !Array.isArray(structured)) {
|
|
@@ -337,7 +342,8 @@ function shrink(toolName, result, budget, originalChars, protectText = false) {
|
|
|
337
342
|
omittedChars: 0, // filled in below, once the final size is known
|
|
338
343
|
omitted: allOmitted,
|
|
339
344
|
howToGetTheRest: `Call orboto_response_expand { handle: "${handle}", path: "<one of omitted[].path>" } for the omitted `
|
|
340
|
-
+ 'remainder. Omit `path` to list what is available. The handle
|
|
345
|
+
+ 'remainder. Omit `path` to list what is available. The handle lives in this MCP server process and '
|
|
346
|
+
+ `expires 15 minutes after this call. ${UNCUT_ELSEWHERE}`,
|
|
341
347
|
...(atFloor ? { atFloor: true } : {}),
|
|
342
348
|
};
|
|
343
349
|
structured.__truncation = block;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* ORB-1697 - the central response budget.
|
|
3
3
|
*/
|
|
4
4
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
5
|
-
import { applyResponseBudget, budgetFor, budgetEnabled, measureHalves, measureResult, readPayload, resetPayloadStore, resolvePath, DEFAULT_BUDGET_CHARS, MAX_HANDLES, HANDLE_TTL_MS, storePayload, PROTECT_TEXT_META, } from './response-budget.js';
|
|
5
|
+
import { applyResponseBudget, budgetFor, budgetEnabled, measureHalves, measureResult, readPayload, resetPayloadStore, resolvePath, DEFAULT_BUDGET_CHARS, MAX_HANDLES, HANDLE_TTL_MS, storePayload, PROTECT_TEXT_META, UNCUT_ELSEWHERE, } from './response-budget.js';
|
|
6
6
|
import { makeResponseExpandHandler } from './tools/response-expand.js';
|
|
7
7
|
const TOOL = 'orboto_test_tool';
|
|
8
8
|
function result(structured, text = 'text block') {
|
|
@@ -84,6 +84,19 @@ describe('over budget', () => {
|
|
|
84
84
|
expect(text).toContain('orboto_response_expand');
|
|
85
85
|
expect(text).toContain(out.handle);
|
|
86
86
|
});
|
|
87
|
+
it('ORB-2164: the hint names BOTH ways out - the expand handle and the uncut body over REST or the CLI', () => {
|
|
88
|
+
const out = applyResponseBudget(TOOL, result({ description: 'd'.repeat(30_000) }));
|
|
89
|
+
const block = truncationOf(out.result);
|
|
90
|
+
const how = String(block.howToGetTheRest);
|
|
91
|
+
expect(how).toContain('orboto_response_expand');
|
|
92
|
+
expect(how).toContain('expires 15 minutes');
|
|
93
|
+
expect(how).toContain('this MCP server process');
|
|
94
|
+
expect(how).toContain(UNCUT_ELSEWHERE);
|
|
95
|
+
const text = out.result.content[0].text;
|
|
96
|
+
expect(text).toContain(UNCUT_ELSEWHERE);
|
|
97
|
+
expect(UNCUT_ELSEWHERE).toContain('REST API and both CLIs');
|
|
98
|
+
expect(UNCUT_ELSEWHERE).toContain('no CLI expand');
|
|
99
|
+
});
|
|
87
100
|
it('marks the cut inside the value, so a reader cannot mistake it for the whole', () => {
|
|
88
101
|
const out = applyResponseBudget(TOOL, result({ description: 'd'.repeat(30_000) }));
|
|
89
102
|
const sc = out.result.structuredContent;
|
|
@@ -149,7 +149,7 @@ describe('orboto_agent_notify', () => {
|
|
|
149
149
|
});
|
|
150
150
|
expect(result.content[0].text).toContain('notified bob@example.com');
|
|
151
151
|
expect(result.structuredContent).toMatchObject({ ok: true });
|
|
152
|
-
expect(capturedBody[0].senderRef).toMatch(/^
|
|
152
|
+
expect(capturedBody[0].senderRef).toMatch(/^agent-/);
|
|
153
153
|
});
|
|
154
154
|
it('ORB-1742: senderRef prefers the per-connection MCP session id, and an explicit ref wins outright', async () => {
|
|
155
155
|
const capturedBody = [];
|
|
@@ -34,11 +34,16 @@ export const AgentInventoryEntrySchema = z.object({
|
|
|
34
34
|
scope: AgentSessionScopeSchema.nullable(),
|
|
35
35
|
/** ORB-2136 - unread inbox messages this session would list. */
|
|
36
36
|
unreadMessages: z.number().int(),
|
|
37
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* ORB-2149 - the last 24 hours of wakes: how many deliveries, how precise,
|
|
39
|
+
* what the wrong ones cost. ORB-2170 - `backlogSeen` is the `nudge` count
|
|
40
|
+
* over the same window, a backlog sighting rather than a wake.
|
|
41
|
+
*/
|
|
38
42
|
wakes: z.object({
|
|
39
43
|
count: z.number().int(),
|
|
40
44
|
precision: z.number().nullable(),
|
|
41
45
|
wrongWakeContextTokens: z.number().int(),
|
|
46
|
+
backlogSeen: z.number().int(),
|
|
42
47
|
}),
|
|
43
48
|
lastSeenAt: z.string(),
|
|
44
49
|
createdAt: z.string(),
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
export const agentWakesToolConfig = {
|
|
7
7
|
title: 'Wake ledger',
|
|
8
|
-
description: 'Who woke this account\'s sessions, which visibility rule delivered the message (addressed | scope_project | scope_ticket | broadcast | unscoped_session | account_view), what the woken session did with it (handled | not_mine | obsolete | duplicate | expired | pending) and what the wake cost in context tokens. `stats: true` returns delivery precision per woken session and per sender over `hours` (operator read, needs admin:agents:read);
|
|
8
|
+
description: 'Who woke this account\'s sessions, which visibility rule delivered the message (addressed | scope_project | scope_ticket | broadcast | unscoped_session | account_view), what the woken session did with it (handled | not_mine | obsolete | duplicate | expired | pending) and what the wake cost in context tokens. `stats: true` returns delivery precision per woken session and per sender over `hours` (operator read, needs admin:agents:read); `wakes`, `precision` and the median latency count deliveries (`channel` | `follow` | `poll`) only, `backlogSeen` is the separate count of `nudge` rows - a backlog sighting, not a wake. Without `stats` it returns the ledger rows of your own account, newest first. Use it to prove a misrouting sender instead of describing it in prose.',
|
|
9
9
|
inputSchema: z.object({
|
|
10
10
|
stats: z.boolean().default(false).describe('Precision per session and per sender instead of rows.'),
|
|
11
11
|
hours: z.number().int().min(1).max(720).optional().describe('Stats window, default 24.'),
|
|
@@ -20,7 +20,8 @@ export const agentWakesToolConfig = {
|
|
|
20
20
|
function statsLine(e) {
|
|
21
21
|
const precision = e.precision === null ? 'unjudged' : `${Math.round(e.precision * 100)}%`;
|
|
22
22
|
const tokens = e.wrongWakeContextTokens > 0 ? `, wrong wakes cost ${e.wrongWakeContextTokens} tokens` : '';
|
|
23
|
-
|
|
23
|
+
const backlog = e.backlogSeen > 0 ? `, ${e.backlogSeen} backlog seen` : '';
|
|
24
|
+
return `- ${e.label}: ${e.wakes} wakes, precision ${precision}, median ${e.medianLatencyMs ?? '?'} ms, top rule ${e.topRule ?? '-'}${tokens}${backlog}`;
|
|
24
25
|
}
|
|
25
26
|
export function makeAgentWakesHandler(client) {
|
|
26
27
|
return async (args) => {
|
|
@@ -109,6 +109,6 @@ describe('orboto_api_call', () => {
|
|
|
109
109
|
await makeApiCallHandler(client)({ method: 'POST', path: '/v1/agent/heartbeat', body: { scope: { role: 'spec' } } }, { sessionId: 'abc123' });
|
|
110
110
|
expect(seen[0]['x-orboto-agent-session']).toBe('mcp-abc123');
|
|
111
111
|
await makeApiCallHandler(client)({ method: 'GET', path: '/v1/agent/messages' });
|
|
112
|
-
expect(seen[1]['x-orboto-agent-session']).toMatch(/^
|
|
112
|
+
expect(seen[1]['x-orboto-agent-session']).toMatch(/^agent-/);
|
|
113
113
|
});
|
|
114
114
|
});
|
|
@@ -82,7 +82,7 @@ export function makeListDocCommentsHandler(client) {
|
|
|
82
82
|
}
|
|
83
83
|
export const postDocCommentToolConfig = {
|
|
84
84
|
title: 'Post a comment on a doc page (or reply to one)',
|
|
85
|
-
description: 'Add a comment to a doc page. Pass `parentCommentId` to reply (the API flattens reply chains past one level so a reply-of-reply lands as a sibling of the original reply). Optional `anchor` (`{text, before, after}`) attaches the comment to a specific highlight in the body - the frontend uses the surrounding context to re-locate the anchor even after the doc has been edited.
|
|
85
|
+
description: 'Add a comment to a doc page. Pass `parentCommentId` to reply (the API flattens reply chains past one level so a reply-of-reply lands as a sibling of the original reply). Optional `anchor` (`{text, before, after}`) attaches the comment to a specific highlight in the body - the frontend uses the surrounding context to re-locate the anchor even after the doc has been edited. Mention a person with the markup `@[Full Name](user:<uuid>)` - the UUIDs come from `orboto_list_users`. The response reports `mentions` (tokens that resolved to a user, who is then notified) and `unresolvedMentions` (tokens that named nobody); a mention is never silently dropped.',
|
|
86
86
|
inputSchema: z.object({
|
|
87
87
|
docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
|
|
88
88
|
content: z.string().min(1).max(4000),
|
|
@@ -104,10 +104,20 @@ export function makePostDocCommentHandler(client) {
|
|
|
104
104
|
if (anchor)
|
|
105
105
|
body.anchor = anchor;
|
|
106
106
|
const row = await client.post(`/docs/${docId}/comments`, body);
|
|
107
|
+
const resolved = row.mentions ?? [];
|
|
108
|
+
const unresolved = row.unresolvedMentions ?? [];
|
|
109
|
+
const mentionParts = [];
|
|
110
|
+
if (resolved.length > 0)
|
|
111
|
+
mentionParts.push(`notified ${resolved.map((m) => m.fullName ?? m.email).join(', ')}`);
|
|
112
|
+
if (unresolved.length > 0)
|
|
113
|
+
mentionParts.push(`UNRESOLVED ${unresolved.map((m) => `${m.token} (${m.reason})`).join(', ')}`);
|
|
107
114
|
return {
|
|
108
115
|
content: [{
|
|
109
116
|
type: 'text',
|
|
110
|
-
text:
|
|
117
|
+
text: [
|
|
118
|
+
`Posted comment ${row.id} on doc ${docId}${row.parentCommentId ? ` (reply to ${row.parentCommentId})` : ''}.`,
|
|
119
|
+
...(mentionParts.length > 0 ? [`Mentions: ${mentionParts.join(' · ')}`] : []),
|
|
120
|
+
].join('\n'),
|
|
111
121
|
}],
|
|
112
122
|
structuredContent: {
|
|
113
123
|
id: row.id,
|
|
@@ -116,6 +126,8 @@ export function makePostDocCommentHandler(client) {
|
|
|
116
126
|
content: row.content,
|
|
117
127
|
anchor: row.anchor,
|
|
118
128
|
createdAt: row.createdAt,
|
|
129
|
+
mentions: resolved,
|
|
130
|
+
unresolvedMentions: unresolved,
|
|
119
131
|
},
|
|
120
132
|
};
|
|
121
133
|
};
|
package/dist/tools/docs-ai.js
CHANGED
|
@@ -90,7 +90,7 @@ function mimetypeFor(filename) {
|
|
|
90
90
|
}
|
|
91
91
|
export const ingestFileToolConfig = {
|
|
92
92
|
title: 'Ingest a local file into a wiki space (multipart upload)',
|
|
93
|
-
description: 'Upload a file (PDF / DOCX / Markdown / plain text) into `spaceId` via multipart. The model passes the bytes as a base64 `contentBase64` field (so this works without local FS access on the agent side). Use `filename` to give the doc a meaningful title - the backend sniffs the mimetype but also uses the filename for display. Optional `parentDocId` nests under an existing doc.',
|
|
93
|
+
description: 'Upload a file (PDF / DOCX / Markdown / plain text) into `spaceId` via multipart. The model passes the bytes as a base64 `contentBase64` field (so this works without local FS access on the agent side). Use `filename` to give the doc a meaningful title - the backend sniffs the mimetype but also uses the filename for display. Optional `parentDocId` nests under an existing doc. The uploaded file is ALSO attached to the doc it produces (ORB-2167): the response names `attachmentId`, `orboto_list_doc_attachments` lists it and `orboto_get_attachment` downloads the original bytes, so the extracted text and its source stay together.',
|
|
94
94
|
inputSchema: z.object({
|
|
95
95
|
spaceId: z.string().uuid(),
|
|
96
96
|
filename: z.string().min(1).describe('Display filename, e.g. "ADR-12-secrets-rotation.pdf".'),
|
|
@@ -124,6 +124,7 @@ export function makeIngestFileHandler(client) {
|
|
|
124
124
|
` slug: ${res.slug}`,
|
|
125
125
|
` format: ${res.kind}`,
|
|
126
126
|
` uploaded ${Math.round(res.sizeBytes / 1024)} KB → ${res.markdownChars} chars of markdown`,
|
|
127
|
+
` source attached: ${res.attachmentId ?? '(none)'}`,
|
|
127
128
|
];
|
|
128
129
|
return {
|
|
129
130
|
content: [{ type: 'text', text: lines.join('\n') }],
|
|
@@ -134,6 +135,7 @@ export function makeIngestFileHandler(client) {
|
|
|
134
135
|
kind: res.kind,
|
|
135
136
|
sizeBytes: res.sizeBytes,
|
|
136
137
|
markdownChars: res.markdownChars,
|
|
138
|
+
attachmentId: res.attachmentId ?? null,
|
|
137
139
|
},
|
|
138
140
|
};
|
|
139
141
|
};
|
|
@@ -7,7 +7,7 @@ import { z } from 'zod';
|
|
|
7
7
|
import { resolveProjectByKey } from './shared.js';
|
|
8
8
|
export const getProjectPrimerToolConfig = {
|
|
9
9
|
title: 'Get project primer (AI Context Pack)',
|
|
10
|
-
description: 'Returns the project\'s auto-generated session primer in a single call: active milestones, ticket counts, recently-closed milestones, top docs, structured primer facts, and recent activity. Primer facts merge three scopes in order Workspace -> Project -> Personal: the Personal block ("Your preferences") is the calling user\'s own personal-scope facts, included only when that user has opted in (ai_preferences_enabled) and never showing another user\'s. Repo briefings (CLAUDE.md / AGENTS.md) are included ONLY when the operator configured them AND the API host can read those files from disk - most deployments will not have them, so do not assume CLAUDE.md / AGENTS.md content is in the response. Token-budget aware - pass `maxTokens` to constrain the output. Input is the project key (e.g. "ORB"), case-insensitive.',
|
|
10
|
+
description: 'Returns the project\'s auto-generated session primer in a single call: the project vocabulary a create has to get right (ticket type enum, the project\'s labels, its status names per category, its open milestone keys), active milestones, ticket counts, recently-closed milestones, top docs, structured primer facts, and recent activity. Primer facts merge three scopes in order Workspace -> Project -> Personal: the Personal block ("Your preferences") is the calling user\'s own personal-scope facts, included only when that user has opted in (ai_preferences_enabled) and never showing another user\'s. Repo briefings (CLAUDE.md / AGENTS.md) are included ONLY when the operator configured them AND the API host can read those files from disk - most deployments will not have them, so do not assume CLAUDE.md / AGENTS.md content is in the response. Token-budget aware - pass `maxTokens` to constrain the output. Input is the project key (e.g. "ORB"), case-insensitive.',
|
|
11
11
|
inputSchema: z.object({
|
|
12
12
|
projectKey: z.string().min(1).describe('Project key, case-insensitive.'),
|
|
13
13
|
maxTokens: z.number().int().positive().max(200000).optional()
|
package/dist/tools/get-ticket.js
CHANGED
|
@@ -6,11 +6,11 @@ import { specReleaseInfo } from './shared.js';
|
|
|
6
6
|
*/
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
import { OrbotoApiError } from '../orboto-client.js';
|
|
9
|
-
import { resolveTicketByKey } from './shared.js';
|
|
9
|
+
import { formatDependencySummary, resolveTicketByKey } from './shared.js';
|
|
10
10
|
const COMMENT_PAGE_SIZE = 50;
|
|
11
11
|
export const getTicketToolConfig = {
|
|
12
12
|
title: 'Get ticket details',
|
|
13
|
-
description: 'Return a ticket\'s decision card: description, status, priority, milestone, assignees, labels, dates, estimate/logged time, checklist progress, parent + sub-ticket count - plus COUNTS for everything omitted (commentCount, gitActivityCount, attachmentCount, childCount). Full blocks are opt-in via `include` (ORB-1698): pass e.g. include: ["comments"] to get the comment bodies, ["git","attachments","children","raci","checklistItems"] likewise. Input is the ticket key like "ACME-42".',
|
|
13
|
+
description: 'Return a ticket\'s decision card: description, status, priority, milestone, assignees, labels, dates, estimate/logged time, checklist progress, parent + sub-ticket count, the OPEN dependency edges in both directions ("Blocked by" / "Blocks", keys + statuses - the full graph stays on orboto_list_ticket_dependencies) - plus COUNTS for everything omitted (commentCount, gitActivityCount, attachmentCount, childCount). Full blocks are opt-in via `include` (ORB-1698): pass e.g. include: ["comments"] to get the comment bodies, ["git","attachments","children","raci","checklistItems"] likewise. Input is the ticket key like "ACME-42".',
|
|
14
14
|
inputSchema: z.object({
|
|
15
15
|
ticketKey: z.string().min(3).describe('Ticket key like "ACME-42".'),
|
|
16
16
|
include: z.array(z.enum(['comments', 'git', 'attachments', 'children', 'raci', 'checklistItems']))
|
|
@@ -91,6 +91,15 @@ export function makeGetTicketHandler(client) {
|
|
|
91
91
|
statusCategory: parent.statusCategory ?? null,
|
|
92
92
|
} : null,
|
|
93
93
|
childCount: children.length,
|
|
94
|
+
webUrl: full.webUrl ?? null,
|
|
95
|
+
blockedBy: {
|
|
96
|
+
openCount: full.blockedByOpenCount ?? 0,
|
|
97
|
+
tickets: (full.blockedByOpen ?? []).map(dependencyEdge),
|
|
98
|
+
},
|
|
99
|
+
blocks: {
|
|
100
|
+
openCount: full.blocksOpenCount ?? 0,
|
|
101
|
+
tickets: (full.blocksOpen ?? []).map(dependencyEdge),
|
|
102
|
+
},
|
|
94
103
|
...(inc.has('children') ? {
|
|
95
104
|
children: children.map((c) => ({
|
|
96
105
|
key: c.ticketKey,
|
|
@@ -166,6 +175,14 @@ function swallow404(fallback) {
|
|
|
166
175
|
throw err;
|
|
167
176
|
};
|
|
168
177
|
}
|
|
178
|
+
function dependencyEdge(e) {
|
|
179
|
+
return {
|
|
180
|
+
key: e.ticketKey,
|
|
181
|
+
title: e.title,
|
|
182
|
+
status: e.statusName ?? null,
|
|
183
|
+
...(e.external ? { external: true } : {}),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
169
186
|
function formatTicket(ticket, inc, comments, hasMoreComments, checklists, gitActivity, parent, children, attachments, includeHint) {
|
|
170
187
|
const header = [
|
|
171
188
|
`[${ticket.ticketKey}] ${ticket.title}`,
|
|
@@ -197,6 +214,9 @@ function formatTicket(ticket, inc, comments, hasMoreComments, checklists, gitAct
|
|
|
197
214
|
ticket.labels && ticket.labels.length > 0
|
|
198
215
|
? `Labels: ${ticket.labels.map((l) => l.name).join(', ')}`
|
|
199
216
|
: null,
|
|
217
|
+
ticket.webUrl ? `Link: ${ticket.webUrl}` : null,
|
|
218
|
+
formatDependencySummary('Blocked by', ticket.blockedByOpenCount, ticket.blockedByOpen),
|
|
219
|
+
formatDependencySummary('Blocks', ticket.blocksOpenCount, ticket.blocksOpen),
|
|
200
220
|
].filter((s) => s !== null);
|
|
201
221
|
if (inc.has('children') && children.length > 0) {
|
|
202
222
|
header.push('', 'Children:');
|
package/dist/tools/get-timer.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
export const getTimerToolConfig = {
|
|
6
6
|
title: 'Get current timer',
|
|
7
|
-
description: 'Return the caller\'s currently-running stopwatch (or null if no timer is active).',
|
|
7
|
+
description: 'Return the caller\'s currently-running stopwatch (or null if no timer is active). The lane is resolved from this session\'s instance token, so a timer started by `orboto_claim` / `orboto_timer_start` in the same checkout reads back (ORB-2137); `laneFallback: true` means the timer was matched as the account\'s only running one because this instance\'s lane was empty - another instance started it.',
|
|
8
8
|
inputSchema: z.object({}).shape,
|
|
9
9
|
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
10
10
|
};
|
|
@@ -29,6 +29,7 @@ export function makeGetTimerHandler(client) {
|
|
|
29
29
|
`Elapsed: ${minutes} min (${totalSeconds}s total)`,
|
|
30
30
|
`Started: ${timer.startedAt}`,
|
|
31
31
|
isPaused ? `Paused: ${timer.pausedAt}` : null,
|
|
32
|
+
timer.laneFallback ? 'Matched via the single-active-timer fallback - another instance started it.' : null,
|
|
32
33
|
timer.description ? `Note: ${timer.description}` : null,
|
|
33
34
|
].filter((l) => l !== null).join('\n');
|
|
34
35
|
return {
|
|
@@ -44,6 +45,7 @@ export function makeGetTimerHandler(client) {
|
|
|
44
45
|
accumulatedSeconds: timer.accumulatedSeconds,
|
|
45
46
|
totalSeconds,
|
|
46
47
|
paused: isPaused,
|
|
48
|
+
laneFallback: timer.laneFallback === true,
|
|
47
49
|
},
|
|
48
50
|
},
|
|
49
51
|
};
|
package/dist/tools/help.js
CHANGED
|
@@ -10,14 +10,15 @@
|
|
|
10
10
|
import { z } from 'zod';
|
|
11
11
|
import { getToolDoc, listToolDocNames } from '../tool-docs.js';
|
|
12
12
|
import { connectScriptText } from '../connect-script.js';
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
import { onboardingText } from '../agent-head.js';
|
|
14
|
+
/** ORB-2157, ORB-2161 - guidance that belongs to no single tool, addressed by topic. */
|
|
15
|
+
const TOPICS = { connect: connectScriptText, onboarding: onboardingText };
|
|
15
16
|
export const helpToolConfig = {
|
|
16
17
|
title: 'Full guidance for one orboto tool',
|
|
17
|
-
description: 'Return the complete guidance text (workflows, warnings, edge cases) for one orboto tool by name - manifest descriptions are one-line summaries, this is the rest. Call it before first use of an unfamiliar write tool. topic: "connect" returns the step-by-step script for setting a person up
|
|
18
|
+
description: 'Return the complete guidance text (workflows, warnings, edge cases) for one orboto tool by name - manifest descriptions are one-line summaries, this is the rest. Call it before first use of an unfamiliar write tool. topic: "connect" returns the step-by-step script for setting a person up, topic: "onboarding" the first five minutes and the work loop.',
|
|
18
19
|
inputSchema: z.object({
|
|
19
20
|
tool: z.string().min(1).max(128).optional().describe('Tool name, e.g. orboto_create_ticket.'),
|
|
20
|
-
topic: z.enum(['connect']).optional().describe('Guidance topic instead of a tool.'),
|
|
21
|
+
topic: z.enum(['connect', 'onboarding']).optional().describe('Guidance topic instead of a tool.'),
|
|
21
22
|
}).shape,
|
|
22
23
|
outputSchema: z.object({
|
|
23
24
|
tool: z.string(),
|
|
@@ -192,6 +192,57 @@ describe('orboto_get_ticket', () => {
|
|
|
192
192
|
expect(text).toContain('Comments: 3');
|
|
193
193
|
expect(text).toContain('Checklists: 1/2 done');
|
|
194
194
|
});
|
|
195
|
+
it('ORB-2169: the card shows the open dependency edges in both directions', async () => {
|
|
196
|
+
stub([
|
|
197
|
+
{ json: PROJ },
|
|
198
|
+
{ json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-5', title: 'Bug', status: 'TODO', statusName: 'To Do', type: 'bug', priority: 'normal', gitActivityCount: 0, parentTicketId: null } },
|
|
199
|
+
{ json: {
|
|
200
|
+
id: 't1', projectId: 'p1', ticketKey: 'ACME-5', title: 'Bug', status: 'TODO', statusName: 'To Do',
|
|
201
|
+
statusCategory: 'todo', type: 'bug', priority: 'normal', commentCount: 0, gitActivityCount: 0,
|
|
202
|
+
blockedByOpenCount: 2,
|
|
203
|
+
blockedByOpen: [
|
|
204
|
+
{ ticketKey: 'ACME-2', title: 'Schema first', statusName: 'In Progress', external: false },
|
|
205
|
+
{ ticketKey: null, title: null, statusName: null, external: true },
|
|
206
|
+
],
|
|
207
|
+
blocksOpenCount: 1,
|
|
208
|
+
blocksOpen: [{ ticketKey: 'ACME-9', title: 'Rollout', statusName: 'To Do', external: false }],
|
|
209
|
+
} },
|
|
210
|
+
NO_CHILDREN,
|
|
211
|
+
{ json: [] },
|
|
212
|
+
]);
|
|
213
|
+
const res = await makeGetTicketHandler(client)({ ticketKey: 'ACME-5' });
|
|
214
|
+
const text = res.content[0].text;
|
|
215
|
+
expect(text).toContain('Blocked by: 2 open - [ACME-2] Schema first (In Progress), External dependency (access restricted)');
|
|
216
|
+
expect(text).toContain('Blocks: 1 open - [ACME-9] Rollout (To Do)');
|
|
217
|
+
const sc = res.structuredContent;
|
|
218
|
+
expect(sc.blockedBy).toEqual({
|
|
219
|
+
openCount: 2,
|
|
220
|
+
tickets: [
|
|
221
|
+
{ key: 'ACME-2', title: 'Schema first', status: 'In Progress' },
|
|
222
|
+
{ key: null, title: null, status: null, external: true },
|
|
223
|
+
],
|
|
224
|
+
});
|
|
225
|
+
expect(sc.blocks).toEqual({
|
|
226
|
+
openCount: 1,
|
|
227
|
+
tickets: [{ key: 'ACME-9', title: 'Rollout', status: 'To Do' }],
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
it('ORB-2169: a ticket with no dependency edge prints no blocker line', async () => {
|
|
231
|
+
stub([
|
|
232
|
+
{ json: PROJ },
|
|
233
|
+
{ json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-5', title: 'Bug', status: 'TODO', statusName: 'To Do', type: 'bug', priority: 'normal', gitActivityCount: 0, parentTicketId: null } },
|
|
234
|
+
{ json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-5', title: 'Bug', status: 'TODO', statusName: 'To Do', statusCategory: 'todo', type: 'bug', priority: 'normal', commentCount: 0, gitActivityCount: 0, blockedByOpenCount: 0, blockedByOpen: [], blocksOpenCount: 0, blocksOpen: [] } },
|
|
235
|
+
NO_CHILDREN,
|
|
236
|
+
{ json: [] },
|
|
237
|
+
]);
|
|
238
|
+
const res = await makeGetTicketHandler(client)({ ticketKey: 'ACME-5' });
|
|
239
|
+
const text = res.content[0].text;
|
|
240
|
+
expect(text).not.toContain('Blocked by');
|
|
241
|
+
expect(text).not.toContain('Blocks');
|
|
242
|
+
const sc = res.structuredContent;
|
|
243
|
+
expect(sc.blockedBy).toEqual({ openCount: 0, tickets: [] });
|
|
244
|
+
expect(sc.blocks).toEqual({ openCount: 0, tickets: [] });
|
|
245
|
+
});
|
|
195
246
|
it('ORB-1698: the default response for a 30+-comment ticket stays under the 4k budget', async () => {
|
|
196
247
|
stub([
|
|
197
248
|
{ json: PROJ },
|
|
@@ -15,6 +15,7 @@ import { PROTECT_TEXT_META, storePayload } from '../response-budget.js';
|
|
|
15
15
|
import { loadRequiredRules } from '../required-rules.js';
|
|
16
16
|
import { GIT_HEALTH_REASON_TEXT } from './git-health-reasons.js';
|
|
17
17
|
import { channelStartLines } from '../inbox-channel.js';
|
|
18
|
+
import { agentHeadText } from '../agent-head.js';
|
|
18
19
|
export const sessionStartToolConfig = {
|
|
19
20
|
title: 'Load the rules you must follow + re-orient',
|
|
20
21
|
description: 'THE canonical way to LOAD the binding workspace rules you must follow as an agent. Run it as your FIRST action in a session and immediately AFTER any context compaction. Returns a one-line-per-rule INDEX of the binding working-rules plus their hash (or a compact "unchanged" ack on a repeat call within the same connection) - read the index, then call this tool again with `rulesOnly: true` to read the full text of the rules whenever you do not already hold that exact hash, and expand before acting on any rule whose title touches what you are about to do. Also returns your in-progress tickets - each flagged LANDED, IDLE when it has a linked commit but has not moved for days, i.e. finished work you never handed to review - your running timer, and a warning if a project\'s git connection looks unhealthy (commit ingestion may be stalled). Pass `ticketKey` to also get a one-shot bundle for that ticket: project primer, the full ticket with dependencies + checklists, that project\'s git health, and any other agent sessions currently on it - replacing several separate calls. (Do NOT use orboto_list_agent_instructions to read the rules - that tool MANAGES/edits rule blocks for admins; this one is what you read to know how to work.) Registers this session (a heartbeat on its instance row) and is otherwise read-only. '
|
|
@@ -203,7 +204,7 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
203
204
|
? tickets.filter((t) => !t.projectId || t.projectId === scopeProjectId)
|
|
204
205
|
: tickets;
|
|
205
206
|
const elsewhereCount = tickets.length - scopedTickets.length;
|
|
206
|
-
const lines = ['# orboto session start'];
|
|
207
|
+
const lines = [agentHeadText(), '', '# orboto session start'];
|
|
207
208
|
if (me)
|
|
208
209
|
lines.push(`You are ${me.fullName ?? me.email}${me.email ? ` (${me.email})` : ''}.`);
|
|
209
210
|
if (me?.workspaceLocale || me?.locale)
|
package/dist/tools/shared.js
CHANGED
|
@@ -10,6 +10,17 @@ export async function resolveProjectByKey(client, key) {
|
|
|
10
10
|
throw err;
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
|
+
/** ORB-2169 - the "Blocked by" / "Blocks" lines of a ticket decision card. */
|
|
14
|
+
export function formatDependencySummary(label, count, edges) {
|
|
15
|
+
const total = count ?? 0;
|
|
16
|
+
if (total === 0)
|
|
17
|
+
return null;
|
|
18
|
+
const shown = (edges ?? []).map((e) => (e.ticketKey
|
|
19
|
+
? `[${e.ticketKey}] ${e.title ?? ''}${e.statusName ? ` (${e.statusName})` : ''}`.trim()
|
|
20
|
+
: 'External dependency (access restricted)'));
|
|
21
|
+
const more = total > shown.length ? `, +${total - shown.length} more` : '';
|
|
22
|
+
return `${label}: ${total} open - ${shown.join(', ')}${more}`;
|
|
23
|
+
}
|
|
13
24
|
/**
|
|
14
25
|
* ORB-1699 - the ONE agent-facing list-row builder. A list call is a
|
|
15
26
|
* decision aid ("which of these do I open?"); the default row carries
|
|
@@ -95,8 +106,14 @@ export function ticketLine(t) {
|
|
|
95
106
|
parts.push('[waiting on Git ingestion]');
|
|
96
107
|
return parts.join(' ');
|
|
97
108
|
}
|
|
98
|
-
import {
|
|
99
|
-
|
|
109
|
+
import { createHash } from 'node:crypto';
|
|
110
|
+
import { hostname } from 'node:os';
|
|
111
|
+
/** ORB-2171 - the checkout's token, derived exactly like the CLI and the skill wrapper derive theirs; `ORBOTO_AGENT_SESSION` wins. */
|
|
112
|
+
export function checkoutInstanceToken(env = process.env, host = hostname(), cwd = process.cwd()) {
|
|
113
|
+
const explicit = (env.ORBOTO_AGENT_SESSION ?? '').trim();
|
|
114
|
+
return explicit || `agent-${createHash('sha256').update(`${host}:${cwd}`).digest('hex').slice(0, 12)}`;
|
|
115
|
+
}
|
|
116
|
+
const MCP_PROCESS_INSTANCE = checkoutInstanceToken();
|
|
100
117
|
/** Precedence: explicit caller-supplied token > per-connection MCP session id
|
|
101
118
|
* (distinct per client even on a shared HTTP server) > per-process id (stdio). */
|
|
102
119
|
export function mcpInstanceToken(explicit, extra) {
|
|
@@ -163,3 +163,11 @@ describe('resolveByName', () => {
|
|
|
163
163
|
expect(ambiguous?.map((r) => r.id).sort()).toEqual(['m1', 'm3']);
|
|
164
164
|
});
|
|
165
165
|
});
|
|
166
|
+
describe('ORB-2171 - the stdio proxy shares the checkout token with the CLI and the skill wrapper', () => {
|
|
167
|
+
it('ORBOTO_AGENT_SESSION wins; otherwise agent-<sha256(hostname:cwd)[:12]>', async () => {
|
|
168
|
+
const { checkoutInstanceToken } = await import('./shared.js');
|
|
169
|
+
expect(checkoutInstanceToken({ ORBOTO_AGENT_SESSION: ' lane-7 ' }, 'host', '/repo')).toBe('lane-7');
|
|
170
|
+
expect(checkoutInstanceToken({}, 'build-host', '/srv/checkout')).toBe('agent-e0b18afc9cec');
|
|
171
|
+
expect(checkoutInstanceToken({}, 'build-host', '/srv/other')).not.toBe('agent-e0b18afc9cec');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -54,6 +54,7 @@ function ticketStructured(t) {
|
|
|
54
54
|
export const createTicketToolConfig = {
|
|
55
55
|
title: 'Create a ticket',
|
|
56
56
|
description: 'Create a new ticket in the given project. Creating more than ~3 tickets? Use `orboto_bulk_create_tickets` instead - one call, one compact response. Returns the new ticket\'s key (e.g. "ACME-42") so callers can chain follow-ups. **Read the new key from `structuredContent.createdTicketKey` (ORB-1176) - never from `similarWarnings[].ticketKey`, which are OTHER, possibly-duplicate tickets.** The caller must have `ticket:create` on the project. **Duplicate-detection safety-net (ORB-831):** if `similarWarnings` appears in the response with one or more entries, the ticket WAS created but you should review whether to close it as a duplicate of the listed ticket(s) instead. The warnings are advisory - never blocking - but each entry is a ticket the system thinks the new one overlaps with. Prefer `orboto_check_similar` BEFORE creating when you want a dry-run. **Deferred check under load (ORB-1437):** if `duplicateCheckDeferred: true` appears, the project was under a create burst so the duplicate-check was run in the background instead of inline - `similarWarnings` is then empty because it did NOT run synchronously, which is NOT the same as "no duplicates found". A strong match, if any, is posted as an advisory comment on the new ticket a moment later; check the ticket comments before treating it as new work. **Duplicate-check recall (ORB-1121):** when you search/check-similar first, results rank by term co-occurrence - a long, solution-framed title with rare terms can return 0 hits even when a short, symptom-framed dup sharing one distinctive token exists. Probe with a single distinctive STABLE token (file/component/error-string fragment), keep queries SHORT, and search the SYMPTOM not your fix; a 0-result long query is not "no dup". **Language-mismatch warning (ORB-890):** if `languageWarning` appears, the ticket was written in a language different from the workspace default. Consider rewriting in the expected language so search + duplicate-detection stay consistent. Non-blocking. **Before a mass-create (ORB-989):** call `orboto_whoami` first - its `workspaceLocale` field is the language you should write every ticket in. If the same `languageWarning` repeats, stop and clarify the intended language rather than pushing through the whole batch. **Strict mode (ORB-990):** if the workspace enforces ticket language, a mismatch is rejected (the tool returns a `blocked` result, not a created ticket) - rewrite in the workspace language, or set `allowLanguageMismatch: true` only when the language is genuinely intentional. **Hard duplicate-block (ORB-1471):** some workspaces REFUSE a create whose top similarity match is at/above a configured threshold - the tool returns a `duplicateBlocked` result (NOT a created ticket) listing the matching tickets. Extend or comment on one of those instead. If you have confirmed none of them cover this work, retry with `allowDuplicate: true` AND a `duplicateJustification` explaining why - the justification is persisted as a comment on the new ticket. '
|
|
57
|
+
+ '**Pre-flight (ORB-2162):** `dryRun: true` runs the same language, type, label and duplicate checks the write runs and returns their verdicts WITHOUT creating anything - `structuredContent.preflight` carries `ok`, `severity`, `blockedBy` and one verdict per check, each with the exact message the real create would answer with. Use it when a create would otherwise cost a round trip to fix the payload afterwards; re-send the same arguments without `dryRun` once the verdicts are clean. '
|
|
57
58
|
+ '**Parameter notes.** `deliveryMode` (ORB-1608) is the role-aware commit policy that replaced the blanket one-commit-per-ticket rule: implementation/docs expect exactly one linked commit (closing without one is a non-blocking warning); review/admin/epic never expect one - reviews are read-only, admin work carries external evidence, epics derive completion from their children; unset defaults to "epic" when type=epic, else "implementation". `milestone` takes a key ("ORB-M3"), a name, or a UUID and is looked up in the project including closed milestones - unknown or ambiguous is an error, so pass the key/UUID when a name repeats. `labels` and `assigneeEmails` attach ATOMICALLY inside the create (ORB-1416): an unknown label or non-member email rolls the whole create back with a 400, leaving no orphan ticket - there is no separate attach call to retry.',
|
|
58
59
|
inputSchema: z.object({
|
|
59
60
|
projectKey: z.string().min(1).describe('Project key (e.g. "ACME").'),
|
|
@@ -74,12 +75,26 @@ export const createTicketToolConfig = {
|
|
|
74
75
|
allowLanguageMismatch: z.boolean().optional().describe('Override the language block.'),
|
|
75
76
|
allowDuplicate: z.boolean().optional().describe('Override the duplicate block.'),
|
|
76
77
|
duplicateJustification: z.string().optional().describe('Why this is not a duplicate.'),
|
|
78
|
+
dryRun: z.boolean().optional().describe('Pre-flight only: return the verdicts, create nothing.'),
|
|
77
79
|
}).shape,
|
|
78
80
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
79
81
|
};
|
|
80
82
|
export function makeCreateTicketHandler(client) {
|
|
81
83
|
return async (input) => {
|
|
82
84
|
const project = await resolveProjectByKey(client, input.projectKey);
|
|
85
|
+
if (input.dryRun) {
|
|
86
|
+
const parentTicketId = input.parentTicketKey
|
|
87
|
+
? (await resolveTicketByKey(client, input.parentTicketKey)).id
|
|
88
|
+
: undefined;
|
|
89
|
+
const preflight = await client.post(`/projects/${project.id}/tickets/preflight`, {
|
|
90
|
+
title: input.title,
|
|
91
|
+
description: input.description ?? null,
|
|
92
|
+
...(input.type ? { type: input.type } : {}),
|
|
93
|
+
...(input.labels && input.labels.length > 0 ? { labelNames: input.labels } : {}),
|
|
94
|
+
...(parentTicketId ? { parentTicketId } : {}),
|
|
95
|
+
});
|
|
96
|
+
return preflightResult(preflight, input.projectKey);
|
|
97
|
+
}
|
|
83
98
|
const body = {
|
|
84
99
|
title: input.title,
|
|
85
100
|
description: input.description ?? null,
|
|
@@ -153,6 +168,43 @@ export function makeCreateTicketHandler(client) {
|
|
|
153
168
|
};
|
|
154
169
|
};
|
|
155
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* ORB-2162 - render the dry run: one line per verdict plus the next step.
|
|
173
|
+
* Never an error result - a blocking verdict is the answer the caller asked
|
|
174
|
+
* for, not a failed call.
|
|
175
|
+
*/
|
|
176
|
+
function preflightResult(preflight, projectKey) {
|
|
177
|
+
const icon = { ok: '✓', warn: '⚠', block: '⛔' };
|
|
178
|
+
const lines = [
|
|
179
|
+
preflight.ok
|
|
180
|
+
? `✓ Pre-flight clean for ${projectKey} - re-send the same arguments without dryRun to create the ticket.`
|
|
181
|
+
: `⛔ Pre-flight BLOCKED for ${projectKey} (${preflight.blockedBy.join(', ')}) - nothing was created. Fix the payload and run the pre-flight again.`,
|
|
182
|
+
` ${icon[preflight.verdicts.language.severity]} language: ${preflight.verdicts.language.message}`,
|
|
183
|
+
` ${icon[preflight.verdicts.type.severity]} type: ${preflight.verdicts.type.message}`,
|
|
184
|
+
` ${icon[preflight.verdicts.labels.severity]} labels: ${preflight.verdicts.labels.message}`,
|
|
185
|
+
` ${icon[preflight.verdicts.duplicates.severity]} duplicates: ${preflight.verdicts.duplicates.message}`,
|
|
186
|
+
];
|
|
187
|
+
for (const c of preflight.verdicts.duplicates.candidates) {
|
|
188
|
+
lines.push(` - [${c.ticketKey ?? c.id.slice(0, 8)}] "${c.title}" (${formatSimilarity(c)})`);
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
192
|
+
structuredContent: {
|
|
193
|
+
preflight: {
|
|
194
|
+
ok: preflight.ok,
|
|
195
|
+
severity: preflight.severity,
|
|
196
|
+
blockedBy: preflight.blockedBy,
|
|
197
|
+
verdicts: {
|
|
198
|
+
...preflight.verdicts,
|
|
199
|
+
duplicates: {
|
|
200
|
+
...preflight.verdicts.duplicates,
|
|
201
|
+
candidates: trimSimilarEntries(preflight.verdicts.duplicates.candidates),
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
156
208
|
/** Append the summaryWarning (ORB-1332) to a move/close result - extra text
|
|
157
209
|
* line + the structured field - so both the model's prose read and any
|
|
158
210
|
* structured consumer see it. No-op when the backend didn't warn. */
|
|
@@ -354,9 +406,22 @@ export function makeDeleteTicketHandler(client) {
|
|
|
354
406
|
};
|
|
355
407
|
};
|
|
356
408
|
}
|
|
409
|
+
/** ORB-2168 - the "who did this reach" line of a comment write. */
|
|
410
|
+
function mentionLine(created) {
|
|
411
|
+
const resolved = created.mentions ?? [];
|
|
412
|
+
const unresolved = created.unresolvedMentions ?? [];
|
|
413
|
+
if (resolved.length === 0 && unresolved.length === 0)
|
|
414
|
+
return null;
|
|
415
|
+
const parts = [];
|
|
416
|
+
if (resolved.length > 0)
|
|
417
|
+
parts.push(`notified ${resolved.map((m) => m.fullName ?? m.email).join(', ')}`);
|
|
418
|
+
if (unresolved.length > 0)
|
|
419
|
+
parts.push(`UNRESOLVED ${unresolved.map((m) => `${m.token} (${m.reason})`).join(', ')}`);
|
|
420
|
+
return `Mentions: ${parts.join(' · ')}`;
|
|
421
|
+
}
|
|
357
422
|
export const commentToolConfig = {
|
|
358
423
|
title: 'Post a comment',
|
|
359
|
-
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.',
|
|
424
|
+
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. Mention a person with the markup `@[Full Name](user:<uuid>)` - the UUIDs come from `orboto_list_users`. The response reports `mentions` (tokens that resolved to a user, who is then notified) and `unresolvedMentions` (tokens that named nobody); a mention is never silently dropped.',
|
|
360
425
|
inputSchema: z.object({
|
|
361
426
|
ticketKey: z.string().min(3),
|
|
362
427
|
text: z.string().min(1),
|
|
@@ -369,16 +434,22 @@ export function makeCommentHandler(client) {
|
|
|
369
434
|
return async ({ ticketKey, text, isInternal, attachmentDraftIds }) => {
|
|
370
435
|
const ticket = await resolveTicketByKey(client, ticketKey);
|
|
371
436
|
const created = await client.post(`/tickets/${ticket.id}/comments`, { content: text, isInternal: isInternal ?? false, ...(attachmentDraftIds ? { attachmentDraftIds } : {}) });
|
|
437
|
+
const mentions = mentionLine(created);
|
|
372
438
|
return {
|
|
373
439
|
content: [{
|
|
374
440
|
type: 'text',
|
|
375
|
-
text:
|
|
441
|
+
text: [
|
|
442
|
+
`Posted comment on [${ticket.ticketKey}]${created.isInternal ? ' (internal)' : ''}.`,
|
|
443
|
+
...(mentions ? [mentions] : []),
|
|
444
|
+
].join('\n'),
|
|
376
445
|
}],
|
|
377
446
|
structuredContent: {
|
|
378
447
|
ticketKey: ticket.ticketKey,
|
|
379
448
|
commentId: created.id,
|
|
380
449
|
isInternal: created.isInternal,
|
|
381
450
|
createdAt: created.createdAt,
|
|
451
|
+
mentions: created.mentions ?? [],
|
|
452
|
+
unresolvedMentions: created.unresolvedMentions ?? [],
|
|
382
453
|
},
|
|
383
454
|
};
|
|
384
455
|
};
|
|
@@ -275,6 +275,38 @@ describe('orboto_create_ticket', () => {
|
|
|
275
275
|
expect(createUrl).toContain('allowDuplicate=true');
|
|
276
276
|
expect(createBody.duplicateJustification).toBe('Different provider - Stripe vs PayPal.');
|
|
277
277
|
});
|
|
278
|
+
it('dryRun hits the pre-flight route, creates nothing and reports every verdict (ORB-2162)', async () => {
|
|
279
|
+
const calls = stub([
|
|
280
|
+
{ json: PROJ },
|
|
281
|
+
{
|
|
282
|
+
json: {
|
|
283
|
+
ok: false,
|
|
284
|
+
severity: 'block',
|
|
285
|
+
blockedBy: ['labels'],
|
|
286
|
+
verdicts: {
|
|
287
|
+
language: { severity: 'ok', code: null, detected: null, expected: 'en', enforced: false, message: 'Language matches the workspace default (en).' },
|
|
288
|
+
type: { severity: 'ok', value: 'bug', allowed: ['epic', 'story', 'task', 'bug'], message: 'Type "bug" is valid.' },
|
|
289
|
+
labels: { severity: 'block', requested: ['ghost'], resolved: [], unknown: ['ghost'], ambiguous: [], available: ['infra'], message: 'Label "ghost" does not exist on this project. Create it first, then reference it by name.' },
|
|
290
|
+
duplicates: { severity: 'warn', threshold: 0, topSimilarity: 0.41, mode: 'tsvector', message: 'Possible duplicates - review the candidates before creating (top match 41%).', candidates: [{ id: 'x1', ticketKey: 'ACME-3', title: 'Timer reads null', statusName: 'To Do', statusColor: null, statusCategory: 'todo', similarity: 0.41, matchMode: 'tsvector' }] },
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
]);
|
|
295
|
+
const res = await makeCreateTicketHandler(client)({
|
|
296
|
+
projectKey: 'ACME', title: 'Timer reads null after a claim', type: 'bug', labels: ['ghost'], dryRun: true,
|
|
297
|
+
});
|
|
298
|
+
expect(calls[1].method).toBe('POST');
|
|
299
|
+
expect(calls[1].url).toBe('https://orboto.example.com/projects/p1/tickets/preflight');
|
|
300
|
+
expect(calls[1].body).toMatchObject({ title: 'Timer reads null after a claim', type: 'bug', labelNames: ['ghost'] });
|
|
301
|
+
const text = res.content[0].text;
|
|
302
|
+
expect(text).toContain('Pre-flight BLOCKED for ACME (labels)');
|
|
303
|
+
expect(text).toContain('does not exist on this project');
|
|
304
|
+
expect(text).toContain('ACME-3');
|
|
305
|
+
expect(res.isError).toBeUndefined();
|
|
306
|
+
const structured = res.structuredContent;
|
|
307
|
+
expect(structured.preflight.ok).toBe(false);
|
|
308
|
+
expect(structured.preflight.blockedBy).toEqual(['labels']);
|
|
309
|
+
});
|
|
278
310
|
});
|
|
279
311
|
describe('orboto_update_ticket', () => {
|
|
280
312
|
it('PATCHes only the supplied fields, leaves others untouched', async () => {
|
|
@@ -118,7 +118,7 @@ describe('orboto_work_next', () => {
|
|
|
118
118
|
expect(calls[0].method).toBe('POST');
|
|
119
119
|
expect(calls[0].url).toContain('/work-sessions/next');
|
|
120
120
|
expect(calls[0].body?.projectKey).toBe('ACME');
|
|
121
|
-
expect(String(calls[0].body?.agentSessionToken)).toMatch(/^
|
|
121
|
+
expect(String(calls[0].body?.agentSessionToken)).toMatch(/^agent-/);
|
|
122
122
|
const text = res.content[0].text;
|
|
123
123
|
expect(text).toContain('Reserved ACME-42');
|
|
124
124
|
expect(text).toContain('ws1');
|
|
@@ -89,7 +89,7 @@ describe('orboto_work_start', () => {
|
|
|
89
89
|
expect(post.method).toBe('POST');
|
|
90
90
|
expect(post.url).toContain('/work-sessions/start');
|
|
91
91
|
expect(post.body?.ticketId).toBe('t1');
|
|
92
|
-
expect(String(post.body?.agentSessionToken)).toMatch(/^
|
|
92
|
+
expect(String(post.body?.agentSessionToken)).toMatch(/^agent-/);
|
|
93
93
|
const text = res.content[0].text;
|
|
94
94
|
expect(text).toContain('Started a implementation work session on ACME-42');
|
|
95
95
|
expect(text).toContain('ws1');
|
|
@@ -8,6 +8,16 @@ import { mcpInstanceToken, resolveTicketByKey } from './shared.js';
|
|
|
8
8
|
import { validateRulesReceipt } from '../required-rules.js';
|
|
9
9
|
export { makeWorkNextHandler, workNextToolConfig } from './work-next.js';
|
|
10
10
|
import { GIT_HEALTH_REASON_TEXT, ResourceClaimShape, } from './work-sessions-shared.js';
|
|
11
|
+
/** ORB-2173 - the phrase for a `bookedBy` value other than `finish` (the
|
|
12
|
+
* ordinary "this call stopped it" path, which stays silent). */
|
|
13
|
+
function describeBookedBy(bookedBy) {
|
|
14
|
+
switch (bookedBy) {
|
|
15
|
+
case 'transition': return 'by the status transition';
|
|
16
|
+
case 'lane_stop': return 'by an earlier timer stop';
|
|
17
|
+
case 'expiry': return 'by the lease expiring';
|
|
18
|
+
default: return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
11
21
|
function parseClaimConflicts(err) {
|
|
12
22
|
try {
|
|
13
23
|
return JSON.parse(err.body).claimConflicts;
|
|
@@ -328,12 +338,21 @@ export function makeWorkSessionFinishHandler(client) {
|
|
|
328
338
|
...(args.commitSha ? { commitSha: args.commitSha } : {}),
|
|
329
339
|
...(args.verification ? { verification: args.verification } : {}),
|
|
330
340
|
});
|
|
341
|
+
const bookedByPhrase = describeBookedBy(res.bookedBy);
|
|
331
342
|
const text = res.changed
|
|
332
|
-
? `Session ${args.sessionId} ${res.session.status}. Booked ${res.durationMinutes} min; the ${res.session.role} lease on ${res.session.ticketKey ?? res.session.ticketId} is free.`
|
|
333
|
-
:
|
|
343
|
+
? `Session ${args.sessionId} ${res.session.status}. Booked ${res.durationMinutes} min${bookedByPhrase ? ` (${bookedByPhrase})` : ''}; the ${res.session.role} lease on ${res.session.ticketKey ?? res.session.ticketId} is free.`
|
|
344
|
+
: res.durationMinutes > 0
|
|
345
|
+
? `Session ${args.sessionId} was already ${res.session.status} - ${res.durationMinutes} min already booked${bookedByPhrase ? ` (${bookedByPhrase})` : ''}. (Idempotent finish.)`
|
|
346
|
+
: `Session ${args.sessionId} was already ${res.session.status} - nothing to book. (Idempotent finish.)`;
|
|
334
347
|
return {
|
|
335
348
|
content: [{ type: 'text', text }],
|
|
336
|
-
structuredContent: {
|
|
349
|
+
structuredContent: {
|
|
350
|
+
session: res.session,
|
|
351
|
+
durationMinutes: res.durationMinutes,
|
|
352
|
+
timeEntryId: res.timeEntryId,
|
|
353
|
+
bookedBy: res.bookedBy,
|
|
354
|
+
changed: res.changed,
|
|
355
|
+
},
|
|
337
356
|
};
|
|
338
357
|
};
|
|
339
358
|
}
|
|
@@ -368,10 +387,13 @@ export function makeWorkFinishHandler(client) {
|
|
|
368
387
|
...(args.targetCategory ? { targetCategory: args.targetCategory } : {}),
|
|
369
388
|
...(args.note ? { note: args.note } : {}),
|
|
370
389
|
});
|
|
390
|
+
const bookedByPhrase = describeBookedBy(res.bookedBy);
|
|
371
391
|
const lines = [
|
|
372
392
|
res.changed
|
|
373
|
-
? `Session ${args.sessionId} ${res.session.status}. Booked ${res.durationMinutes} min; the ${res.session.role} lease on ${res.session.ticketKey ?? res.session.ticketId} is free.`
|
|
374
|
-
:
|
|
393
|
+
? `Session ${args.sessionId} ${res.session.status}. Booked ${res.durationMinutes} min${bookedByPhrase ? ` (${bookedByPhrase})` : ''}; the ${res.session.role} lease on ${res.session.ticketKey ?? res.session.ticketId} is free.`
|
|
394
|
+
: res.durationMinutes > 0
|
|
395
|
+
? `Session ${args.sessionId} was already ${res.session.status} - ${res.durationMinutes} min already booked${bookedByPhrase ? ` (${bookedByPhrase})` : ''}. (Idempotent finish.)`
|
|
396
|
+
: `Session ${args.sessionId} was already ${res.session.status} - nothing to book. (Idempotent finish.)`,
|
|
375
397
|
];
|
|
376
398
|
if (res.session.commitSha) {
|
|
377
399
|
lines.push(`Commit ${res.session.commitSha}${res.session.commitVerified ? ' (verified by git ingestion)' : ' (attested - pending git verification)'}.`);
|
|
@@ -387,6 +409,8 @@ export function makeWorkFinishHandler(client) {
|
|
|
387
409
|
structuredContent: {
|
|
388
410
|
session: res.session,
|
|
389
411
|
durationMinutes: res.durationMinutes,
|
|
412
|
+
timeEntryId: res.timeEntryId,
|
|
413
|
+
bookedBy: res.bookedBy,
|
|
390
414
|
changed: res.changed,
|
|
391
415
|
ticketTransitioned: res.ticketTransitioned,
|
|
392
416
|
ticketStatusCategory: res.ticketStatusCategory,
|
|
@@ -58,7 +58,7 @@ describe('orboto_work_session_start', () => {
|
|
|
58
58
|
expect(post.method).toBe('POST');
|
|
59
59
|
expect(post.url).toContain('/work-sessions');
|
|
60
60
|
expect(post.body?.ticketId).toBe('t1');
|
|
61
|
-
expect(String(post.body?.agentSessionToken)).toMatch(/^
|
|
61
|
+
expect(String(post.body?.agentSessionToken)).toMatch(/^agent-/);
|
|
62
62
|
const text = res.content[0].text;
|
|
63
63
|
expect(text).toContain('Started a implementation work session on ACME-42');
|
|
64
64
|
expect(text).toContain('ws1');
|
|
@@ -131,6 +131,36 @@ describe('orboto_work_session_finish', () => {
|
|
|
131
131
|
expect(res.isError).toBeUndefined();
|
|
132
132
|
expect(res.content[0].text).toContain('Idempotent finish');
|
|
133
133
|
});
|
|
134
|
+
it('ORB-2173 - reports the minutes a status transition already booked instead of zero', async () => {
|
|
135
|
+
stub([{
|
|
136
|
+
json: {
|
|
137
|
+
session: { ...SESSION, status: 'active', activeTimerId: null },
|
|
138
|
+
durationMinutes: 106,
|
|
139
|
+
timeEntryId: 'te1',
|
|
140
|
+
bookedBy: 'transition',
|
|
141
|
+
changed: true,
|
|
142
|
+
},
|
|
143
|
+
}]);
|
|
144
|
+
const res = await makeWorkSessionFinishHandler(client)({ sessionId: 'ws1' });
|
|
145
|
+
const text = res.content[0].text;
|
|
146
|
+
expect(text).toContain('Booked 106 min (by the status transition)');
|
|
147
|
+
expect(res.structuredContent.bookedBy).toBe('transition');
|
|
148
|
+
});
|
|
149
|
+
it('ORB-2173 - an idempotent re-finish still reports the already-booked minutes and source', async () => {
|
|
150
|
+
stub([{
|
|
151
|
+
json: {
|
|
152
|
+
session: { ...SESSION, status: 'finished' },
|
|
153
|
+
durationMinutes: 42,
|
|
154
|
+
timeEntryId: 'te2',
|
|
155
|
+
bookedBy: 'lane_stop',
|
|
156
|
+
changed: false,
|
|
157
|
+
},
|
|
158
|
+
}]);
|
|
159
|
+
const res = await makeWorkSessionFinishHandler(client)({ sessionId: 'ws1' });
|
|
160
|
+
const text = res.content[0].text;
|
|
161
|
+
expect(text).toContain('42 min already booked (by an earlier timer stop)');
|
|
162
|
+
expect(text).toContain('Idempotent finish');
|
|
163
|
+
});
|
|
134
164
|
});
|
|
135
165
|
describe('ORB-1612 - orboto_work_finish', () => {
|
|
136
166
|
it('reports the ticket transition and the completion note', async () => {
|
|
@@ -196,6 +226,25 @@ describe('ORB-1612 - orboto_work_finish', () => {
|
|
|
196
226
|
expect(text).toContain('Idempotent finish');
|
|
197
227
|
expect(text).toContain('No completion note posted');
|
|
198
228
|
});
|
|
229
|
+
it('ORB-2173 - reports the minutes a status transition already booked instead of zero', async () => {
|
|
230
|
+
stub([
|
|
231
|
+
{
|
|
232
|
+
json: {
|
|
233
|
+
session: { ...SESSION, status: 'active', activeTimerId: null },
|
|
234
|
+
durationMinutes: 106,
|
|
235
|
+
timeEntryId: 'te1',
|
|
236
|
+
bookedBy: 'transition',
|
|
237
|
+
changed: true,
|
|
238
|
+
ticketTransitioned: false,
|
|
239
|
+
ticketStatusCategory: 'in_review',
|
|
240
|
+
noteCommented: true,
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
]);
|
|
244
|
+
const res = await makeWorkFinishHandler(client)({ sessionId: 'ws1' });
|
|
245
|
+
expect(res.content[0].text).toContain('Booked 106 min (by the status transition)');
|
|
246
|
+
expect(res.structuredContent.bookedBy).toBe('transition');
|
|
247
|
+
});
|
|
199
248
|
it('surfaces a deliveryModeWarning without failing the call', async () => {
|
|
200
249
|
stub([
|
|
201
250
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orboto/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.201.0",
|
|
4
4
|
"description": "Model Context Protocol server for orboto - exposes tickets, projects, milestones, time, docs, primer-facts, alerts and absences as MCP tools for Claude Desktop / Cursor / Copilot Chat / any MCP-aware AI client.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|