ticketlens 0.38.29 → 0.38.31
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/README.md +1 -1
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +10 -1
- package/skills/jtb/hooks/recall-nudge-lib.mjs +25 -5
- package/skills/jtb/hooks/recall-nudge-stop.mjs +9 -10
- package/skills/jtb/scripts/fetch-my-tickets.mjs +22 -16
- package/skills/jtb/scripts/fetch-ticket.mjs +62 -32
- package/skills/jtb/scripts/lib/help.mjs +9 -8
- package/skills/jtb/scripts/lib/mcp-server.mjs +199 -3
package/README.md
CHANGED
|
@@ -453,7 +453,7 @@ Every note is scanned before saving — anything shaped like a real secret (API
|
|
|
453
453
|
|
|
454
454
|
**Removing a note:** `ticketlens note delete --id="..." [--ticket=KEY]` removes a note from your local vault. Local only — if it was already pushed to a team, teammates who pulled it keep their copy; deleting it there too is a manager action from the Console (Admin > Recall).
|
|
455
455
|
|
|
456
|
-
**Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `recall_add`, `recall_search`, `ticket_comment`, `ticket_transition`, `ticket_assign`, `ticket_duplicates`, `ticket_link`, `ticket_update`, and `ticket_create` as native tools — any MCP-compatible AI assistant, not just Claude Code, can call them directly instead of constructing a shell command. It's a thin adapter over the exact same code as the CLI commands above — same
|
|
456
|
+
**Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `fetch`, `triage`, `compliance`, `doctor`, `recall_add`, `recall_search`, `ticket_comment`, `ticket_transition`, `ticket_assign`, `ticket_duplicates`, `ticket_link`, `ticket_update`, and `ticket_create` as native tools — any MCP-compatible AI assistant, not just Claude Code, can call them directly instead of constructing a shell command. It's a thin adapter over the exact same code as the CLI commands above — same license gate per tool, same secret scan/local vault/tracker writes, same team sync — nothing is reimplemented. `fetch` and `doctor` are Free; `triage`'s base scan is Free with some options gated Pro/Team, same as the CLI (`ticketlens triage --help`); `compliance` is Free with a 3-checks/month cap, Pro unlimited; every other tool needs Pro. Point your harness's MCP config at it: `{ "command": "ticketlens", "args": ["mcp"] }` — or run `ticketlens mcp install` in a project to write that entry into its `.mcp.json` for you (creates the file if it doesn't exist, merges in if it does — never touches any other entry already there; `--dry-run` to preview first).
|
|
457
457
|
|
|
458
458
|
`note add`'s save confirmation and `recall`'s search results are styled by default in a terminal; add `--plain` to either for bare, pipe-safe output. `recall` always shows each note's file ID (e.g. `[1784135399545-fe01c4.md]`) so you can open it directly (`cat ~/.ticketlens/recall/<PREFIX>/<id>`), or pass `--full` to print the full body content inline instead. Each result shows a relative time (`2h ago`, `3d ago`) rather than a bare date — the full-precision timestamp is always in the note file's own frontmatter.
|
|
459
459
|
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.36.0 -->
|
|
2
2
|
---
|
|
3
3
|
name: jtb
|
|
4
4
|
description: Fetch a Jira ticket's full context (description, comments, linked issues, code references) and assemble a structured TicketBrief for implementation planning. Use when user types /jtb, mentions a Jira ticket key, or wants to plan work from a Jira ticket.
|
|
@@ -106,6 +106,8 @@ Where `$EXTRA_ARGS` are any flags passed (e.g. `--stale=3 --status=QA --profile=
|
|
|
106
106
|
|
|
107
107
|
**IMPORTANT:** Copy the script's stdout and display it directly as your response text (not inside a tool result). This ensures the markdown table renders visibly and URLs are clickable in the terminal. No VCS enrichment, no plan mode. Stop here.
|
|
108
108
|
|
|
109
|
+
If this harness has TicketLens's MCP server configured (a tool named `triage` — often shown as `mcp__ticketlens__triage` — visible in your tool list), prefer it over the bash form: same license gate per option (Free base scan, Pro `save`/`all`/`digest`, Team `assignee`/`sprint`/`export`/`project`/`label`/`priority`) — just no shell command to construct. It accepts `profile`/`stale`/`status`/`sort` plus those gated options; `--push`/`--share` aren't exposed yet — fall back to the bash form for those.
|
|
110
|
+
|
|
109
111
|
---
|
|
110
112
|
|
|
111
113
|
### Collisions subcommand
|
|
@@ -145,6 +147,8 @@ Where `$TICKET_KEY` is the first argument (e.g. `PROD-1234`) and `$EXTRA_ARGS` a
|
|
|
145
147
|
|
|
146
148
|
The script outputs a structured markdown TicketBrief to stdout. If it fails (exit code 1), show the stderr message to the user.
|
|
147
149
|
|
|
150
|
+
If this harness has TicketLens's MCP server configured (a tool named `fetch` — often shown as `mcp__ticketlens__fetch` — visible in your tool list), prefer it over the bash form: same license gate (Free), same cache — just no shell command to construct or stdout to parse. It accepts `ticket`/`profile`/`depth`, matching `$TICKET_KEY`/`--profile`/`--depth` above; other CLI flags (`--summarize`, `--handoff`, `--no-cache`, etc.) aren't exposed yet — fall back to the bash form for those.
|
|
151
|
+
|
|
148
152
|
### Step 2b: Read attached files
|
|
149
153
|
|
|
150
154
|
Check if the TicketBrief contains an `## Attachments` section. If it does, for each line containing a backtick-quoted absolute path, call the Read tool on that path based on file type:
|
|
@@ -400,6 +404,11 @@ Use this evaluation order:
|
|
|
400
404
|
### Privacy
|
|
401
405
|
`--compliance` never sends data anywhere. The diff stays local. All analysis is performed by Claude Code within your session context.
|
|
402
406
|
|
|
407
|
+
### Standalone command
|
|
408
|
+
The same tier-gated check also runs as its own command — `ticketlens compliance PROJ-123` — independent of a full ticket fetch. This is what `ticketlens install-hooks` wires into a pre-push git hook (`ticketlens compliance "$KEY" || exit 1`, gated on a configurable coverage threshold). It shares the same `FREE_LIMIT`/Pro gate and the same compliance ledger as the `--compliance` flag above.
|
|
409
|
+
|
|
410
|
+
If this harness has TicketLens's MCP server configured (a tool named `compliance` — often shown as `mcp__ticketlens__compliance` — visible in your tool list), prefer it over the bash form: same tier gate (Free: 3 checks/month, Pro: unlimited), same report — just no shell command to construct or stdout to parse. It accepts `ticket`/`profile`, matching the standalone command's arguments above.
|
|
411
|
+
|
|
403
412
|
---
|
|
404
413
|
|
|
405
414
|
## Advanced Options
|
|
@@ -118,9 +118,17 @@ export function hasRecentCapture(cwd, now = Date.now()) {
|
|
|
118
118
|
* tool — for note-add. Missing the MCP path here was a real bug: it made
|
|
119
119
|
* the Stop hook nudge even after a note was genuinely captured via MCP,
|
|
120
120
|
* since only the Bash/CLI form was ever recognized.
|
|
121
|
+
*
|
|
122
|
+
* `ticketKey` carries the FIRST matched ticket key's literal text (or
|
|
123
|
+
* `null`), so callers can resolve a profile by ticket-key prefix the same
|
|
124
|
+
* way `resolveConnection()` does — see recall-nudge-stop.mjs. First match,
|
|
125
|
+
* not last: the primary ticket a session is about is normally established
|
|
126
|
+
* early, and picking one deterministic match keeps this function decoupled
|
|
127
|
+
* from profiles.json (it stays a pure transcript reader; profile lookup
|
|
128
|
+
* and its own fallback chain belong entirely to resolveProfile()).
|
|
121
129
|
*/
|
|
122
130
|
export function scanTranscript(transcriptPath) {
|
|
123
|
-
const result = { sawTicketKey: false, sawRecallFlag: false, sawNoteAdd: false };
|
|
131
|
+
const result = { sawTicketKey: false, sawRecallFlag: false, sawNoteAdd: false, ticketKey: null };
|
|
124
132
|
let lines;
|
|
125
133
|
try {
|
|
126
134
|
lines = fs.readFileSync(transcriptPath, 'utf8').split('\n').filter(Boolean);
|
|
@@ -136,10 +144,22 @@ export function scanTranscript(transcriptPath) {
|
|
|
136
144
|
continue;
|
|
137
145
|
}
|
|
138
146
|
|
|
139
|
-
// Ticket-key detection stays broad (whole entry, any role) —
|
|
140
|
-
// the weaker "did ticket work happen at
|
|
141
|
-
// positive
|
|
142
|
-
|
|
147
|
+
// Ticket-key detection stays broad (whole entry, any role) — for
|
|
148
|
+
// sawTicketKey alone, it's only the weaker "did ticket work happen at
|
|
149
|
+
// all" signal, so a rare false positive just means an extra harmless
|
|
150
|
+
// once-per-session check. The captured ticketKey text carries a bit
|
|
151
|
+
// more weight (it also selects which profile's recallStrictness
|
|
152
|
+
// applies, see recall-nudge-stop.mjs), but the ceiling is still just
|
|
153
|
+
// "the wrong local settings value governs one Stop-hook decision" — no
|
|
154
|
+
// credentials or ticket data are read using this key, so a false
|
|
155
|
+
// positive here stays low-consequence, not narrowed further for now.
|
|
156
|
+
if (!result.ticketKey) {
|
|
157
|
+
const match = TICKET_KEY_RE.exec(JSON.stringify(entry));
|
|
158
|
+
if (match) {
|
|
159
|
+
result.sawTicketKey = true;
|
|
160
|
+
result.ticketKey = match[0];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
143
163
|
|
|
144
164
|
if (entry.type !== 'assistant') continue;
|
|
145
165
|
const blocks = entry.message?.content;
|
|
@@ -21,14 +21,13 @@
|
|
|
21
21
|
* profile's recallStrictness — see recall-nudge-lib.mjs's shouldNag() doc
|
|
22
22
|
* comment for the calibration and why strict doesn't widen this further.
|
|
23
23
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* Not fixed here — see design spec §6 for the follow-up.
|
|
24
|
+
* resolveProfile() below is given scanTranscript()'s matched ticket key (not
|
|
25
|
+
* null), so it resolves by ticket-key prefix the same way the brief-injection
|
|
26
|
+
* lever (resolveConnection(ticketKey, ...) in profile-resolver.mjs) does —
|
|
27
|
+
* both levers now agree on which profile's recallStrictness applies for a
|
|
28
|
+
* multi-profile user. When no ticket key was seen, or it matches no profile's
|
|
29
|
+
* ticketPrefixes, this falls through to cwd/projectPaths then the default
|
|
30
|
+
* profile, same as before (backlog #12, design spec §6).
|
|
32
31
|
*/
|
|
33
32
|
|
|
34
33
|
import { readStdinJson, readState, writeState, scanTranscript, hasRecentCapture, writeLastCaptureAt, shouldNag } from './recall-nudge-lib.mjs';
|
|
@@ -41,7 +40,7 @@ const cwd = input?.cwd ?? process.cwd();
|
|
|
41
40
|
|
|
42
41
|
if (!sessionId || !transcriptPath) process.exit(0);
|
|
43
42
|
|
|
44
|
-
const { sawTicketKey, sawRecallFlag, sawNoteAdd } = scanTranscript(transcriptPath);
|
|
43
|
+
const { sawTicketKey, sawRecallFlag, sawNoteAdd, ticketKey } = scanTranscript(transcriptPath);
|
|
45
44
|
|
|
46
45
|
// Refreshed on every check, independent of the once-per-session gate below —
|
|
47
46
|
// a capture that happens AFTER this session already nagged once must still
|
|
@@ -51,7 +50,7 @@ if (sawNoteAdd) writeLastCaptureAt(cwd, Date.now());
|
|
|
51
50
|
const state = readState(sessionId);
|
|
52
51
|
if (state.stopChecked) process.exit(0); // already asked once this session — respect the answer
|
|
53
52
|
|
|
54
|
-
const profile = resolveProfile(
|
|
53
|
+
const profile = resolveProfile(ticketKey, { cwd });
|
|
55
54
|
const recallStrictness = normalizeRecallStrictness(profile?.recallStrictness);
|
|
56
55
|
|
|
57
56
|
if (!shouldNag({ sawTicketKey, sawRecallFlag, sawNoteAdd, recallStrictness })) {
|
|
@@ -57,6 +57,12 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
57
57
|
env = envOrOpts;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// Injectable stderr-equivalent — defaults to the real stream so every existing
|
|
61
|
+
// CLI caller (which never sets opts.stream) is byte-identical to before. Lets
|
|
62
|
+
// a long-lived host (the MCP server) capture banners/errors instead of losing
|
|
63
|
+
// them to a real process.stderr it never reads.
|
|
64
|
+
const stream = opts.stream ?? process.stderr;
|
|
65
|
+
|
|
60
66
|
// Strip leading 'triage' subcommand if present (when called via CLI router)
|
|
61
67
|
if (args[0] === 'triage') args = args.slice(1);
|
|
62
68
|
|
|
@@ -99,7 +105,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
99
105
|
const saveArg = args.find(a => a.startsWith('--save='))?.split('=').slice(1).join('=') ?? null;
|
|
100
106
|
|
|
101
107
|
if (exportArg && exportArg !== 'csv' && exportArg !== 'json') {
|
|
102
|
-
|
|
108
|
+
stream.write(`Error: --export must be csv or json, got: ${exportArg}\n`);
|
|
103
109
|
process.exitCode = 1;
|
|
104
110
|
return;
|
|
105
111
|
}
|
|
@@ -120,14 +126,14 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
120
126
|
// --save=FILE: Pro gate + validate path is not a directory
|
|
121
127
|
if (saveArg) {
|
|
122
128
|
if (!licensedFn('pro', configDir)) {
|
|
123
|
-
upgradeFn('pro', '--save');
|
|
129
|
+
upgradeFn('pro', '--save', { stream });
|
|
124
130
|
process.exitCode = 1;
|
|
125
131
|
return;
|
|
126
132
|
}
|
|
127
133
|
const resolvedSave = resolvePath(saveArg);
|
|
128
134
|
try {
|
|
129
135
|
if (statSync(resolvedSave).isDirectory()) {
|
|
130
|
-
|
|
136
|
+
stream.write(`Error: --save path must be a file, not a directory: ${resolvedSave}\n`);
|
|
131
137
|
process.exitCode = 1;
|
|
132
138
|
return;
|
|
133
139
|
}
|
|
@@ -138,7 +144,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
138
144
|
if (projectArg || labelArg || priorityArg) {
|
|
139
145
|
if (!licensedFn('team', configDir)) {
|
|
140
146
|
const flag = projectArg ? '--project' : labelArg ? '--label' : '--priority';
|
|
141
|
-
upgradeFn('team', flag);
|
|
147
|
+
upgradeFn('team', flag, { stream });
|
|
142
148
|
process.exitCode = 1;
|
|
143
149
|
return;
|
|
144
150
|
}
|
|
@@ -147,14 +153,14 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
147
153
|
// --all: triage all configured profiles in parallel with live status block
|
|
148
154
|
if (allFlag) {
|
|
149
155
|
if (!licensedFn('pro', configDir)) {
|
|
150
|
-
upgradeFn('pro', '--all');
|
|
156
|
+
upgradeFn('pro', '--all', { stream });
|
|
151
157
|
process.exitCode = 1;
|
|
152
158
|
return;
|
|
153
159
|
}
|
|
154
160
|
const profilesConfig = loadProfiles(configDir);
|
|
155
161
|
const profileNames = profilesConfig?.profiles ? Object.keys(profilesConfig.profiles) : [];
|
|
156
162
|
if (profileNames.length === 0) {
|
|
157
|
-
|
|
163
|
+
stream.write('Error: No profiles configured. Run `ticketlens init` first.\n');
|
|
158
164
|
process.exitCode = 1;
|
|
159
165
|
return;
|
|
160
166
|
}
|
|
@@ -234,14 +240,14 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
234
240
|
|
|
235
241
|
// Team-tier gate: --assignee and --sprint require a Team license
|
|
236
242
|
if ((assigneeArg || sprintArg) && !licensedFn('team', configDir)) {
|
|
237
|
-
upgradeFn('team', assigneeArg ? '--assignee' : '--sprint');
|
|
243
|
+
upgradeFn('team', assigneeArg ? '--assignee' : '--sprint', { stream });
|
|
238
244
|
process.exitCode = 1;
|
|
239
245
|
return;
|
|
240
246
|
}
|
|
241
247
|
|
|
242
248
|
// Team-tier gate: --export requires a Team license
|
|
243
249
|
if (exportArg && !licensedFn('team', configDir)) {
|
|
244
|
-
upgradeFn('team', '--export');
|
|
250
|
+
upgradeFn('team', '--export', { stream });
|
|
245
251
|
process.exitCode = 1;
|
|
246
252
|
return;
|
|
247
253
|
}
|
|
@@ -256,14 +262,14 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
256
262
|
configDir,
|
|
257
263
|
profileName,
|
|
258
264
|
cwd,
|
|
259
|
-
onWarning: (w) =>
|
|
265
|
+
onWarning: (w) => stream.write(w + '\n'),
|
|
260
266
|
onProfileNotFound: (info) => { profileError = info; },
|
|
261
267
|
});
|
|
262
268
|
|
|
263
269
|
const hasAuth = conn.pat || (conn.email && conn.apiToken);
|
|
264
270
|
if (!conn.baseUrl || !hasAuth) {
|
|
265
271
|
if (profileError) {
|
|
266
|
-
const picked = await promptProfileSelect(profileError);
|
|
272
|
+
const picked = await promptProfileSelect(profileError, { stream });
|
|
267
273
|
if (picked) {
|
|
268
274
|
// Re-run with the selected profile
|
|
269
275
|
const newArgs = args.filter(a => !a.startsWith('--profile='));
|
|
@@ -275,7 +281,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
275
281
|
const msg = noProfiles
|
|
276
282
|
? 'Error: Could not determine Jira profile.\nRun `ticketlens init` to set up your connection.'
|
|
277
283
|
: 'Error: Could not determine Jira profile. Use --profile=NAME or add projectPaths to ~/.ticketlens/profiles.json';
|
|
278
|
-
|
|
284
|
+
stream.write(msg + '\n');
|
|
279
285
|
}
|
|
280
286
|
process.exitCode = 1;
|
|
281
287
|
return;
|
|
@@ -304,7 +310,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
304
310
|
const priorityClause = priorityArg ? ` AND priority = "${escapeJql(priorityArg.split('=')[1])}"` : '';
|
|
305
311
|
const jql = `${assigneeClause} AND status IN (${statusList})${sprintClause}${projectClause}${labelClause}${priorityClause} ORDER BY updated DESC`;
|
|
306
312
|
|
|
307
|
-
const session = createSession(conn);
|
|
313
|
+
const session = createSession(conn, { stream });
|
|
308
314
|
session.spin(`Connecting to ${session.label}…`);
|
|
309
315
|
|
|
310
316
|
// Fire both requests concurrently — they are independent of each other
|
|
@@ -329,7 +335,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
329
335
|
}
|
|
330
336
|
|
|
331
337
|
session.connected();
|
|
332
|
-
|
|
338
|
+
stream.write('\n');
|
|
333
339
|
|
|
334
340
|
const scanSpinner = createSpinner('Scanning tickets…');
|
|
335
341
|
scanSpinner.start();
|
|
@@ -341,7 +347,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
341
347
|
scanSpinner.stop();
|
|
342
348
|
if (err.status === 400 && err.detail && /does not exist for the field 'status'/.test(err.detail)) {
|
|
343
349
|
const s = session.styler;
|
|
344
|
-
const out =
|
|
350
|
+
const out = stream;
|
|
345
351
|
out.write(`\n ${s.yellow('○')} Status mismatch — checking Jira...\n`);
|
|
346
352
|
try {
|
|
347
353
|
const available = await adapter.fetchStatuses();
|
|
@@ -424,7 +430,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
424
430
|
: currentUser;
|
|
425
431
|
|
|
426
432
|
if (assigneeName) {
|
|
427
|
-
|
|
433
|
+
stream.write(`Viewing ${assigneeName}'s tickets\n\n`);
|
|
428
434
|
}
|
|
429
435
|
|
|
430
436
|
const scored = tickets.map(t => scoreAttention(t, effectiveUser, { staleDays, customRules: conn.attentionRules, staleRule: conn.staleRule ?? null }));
|
|
@@ -445,7 +451,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
445
451
|
// --digest: POST scored results to the digest backend endpoint
|
|
446
452
|
if (digestFlag) {
|
|
447
453
|
if (!licensedFn('pro', configDir)) {
|
|
448
|
-
upgradeFn('pro', '--digest');
|
|
454
|
+
upgradeFn('pro', '--digest', { stream });
|
|
449
455
|
process.exitCode = 1;
|
|
450
456
|
return;
|
|
451
457
|
}
|
|
@@ -472,9 +472,9 @@ const RETRY_OPTIONS = [
|
|
|
472
472
|
];
|
|
473
473
|
|
|
474
474
|
export async function run(args, envOrOpts = process.env, fetcher = globalThis.fetch, configDir = undefined) {
|
|
475
|
-
// Support opts-object injection: run(args, { env, fetcher, configDir, detectVcs, getDiff, print })
|
|
475
|
+
// Support opts-object injection: run(args, { env, fetcher, configDir, detectVcs, getDiff, print, printErr })
|
|
476
476
|
let env, opts;
|
|
477
|
-
if (envOrOpts && typeof envOrOpts === 'object' && !Array.isArray(envOrOpts) && ('env' in envOrOpts || 'fetcher' in envOrOpts || 'print' in envOrOpts || 'detectVcs' in envOrOpts || 'getDiff' in envOrOpts)) {
|
|
477
|
+
if (envOrOpts && typeof envOrOpts === 'object' && !Array.isArray(envOrOpts) && ('env' in envOrOpts || 'fetcher' in envOrOpts || 'print' in envOrOpts || 'printErr' in envOrOpts || 'detectVcs' in envOrOpts || 'getDiff' in envOrOpts)) {
|
|
478
478
|
opts = envOrOpts;
|
|
479
479
|
env = opts.env ?? process.env;
|
|
480
480
|
fetcher = opts.fetcher ?? globalThis.fetch;
|
|
@@ -485,6 +485,27 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
485
485
|
}
|
|
486
486
|
|
|
487
487
|
const printFn = opts.print ?? ((chunk) => process.stdout.write(chunk));
|
|
488
|
+
// Mirrors printFn above — only the bare ticket-fetch path (from `const ticketKey =
|
|
489
|
+
// args.find(...)` onward) uses this; pr/ledger/compliance/review/standup/install-hooks
|
|
490
|
+
// above still write directly to the real process.stderr (out of scope for this change,
|
|
491
|
+
// each gets the same treatment when its own MCP tool is built).
|
|
492
|
+
const printErrFn = opts.printErr ?? ((chunk) => process.stderr.write(chunk));
|
|
493
|
+
// Reused on every recursive self-call in the bare-fetch path (profile-prompt retries)
|
|
494
|
+
// so an injected print/printErr survives the retry instead of silently reverting to
|
|
495
|
+
// the real stdout/stderr.
|
|
496
|
+
const selfOpts = { env, fetcher, configDir, print: printFn, printErr: printErrFn };
|
|
497
|
+
// Passed to every stream-shaped helper (createSession, the profile-prompt pickers,
|
|
498
|
+
// handleUnknownFlags) in the bare-fetch path below. isTTY MUST reflect the real
|
|
499
|
+
// process.stderr.isTTY, not a hardcoded false: these helpers use it to decide
|
|
500
|
+
// whether to render an animated/interactive UI at all, for real CLI users too, not
|
|
501
|
+
// just MCP calls. Their own separate `!process.stdin.setRawMode` gate — always true
|
|
502
|
+
// under the MCP server, since its stdin is the JSON-RPC channel, never a TTY — is
|
|
503
|
+
// what actually keeps the interactive branch from firing under MCP; isTTY here only
|
|
504
|
+
// controls real-terminal UX and must stay accurate for that to keep working. Injectable
|
|
505
|
+
// via opts.isTTY so a test can assert deterministic behavior without mutating the real,
|
|
506
|
+
// process-global process.stderr.isTTY (unsafe to flip mid-suite — see fetch-ticket.test.mjs).
|
|
507
|
+
const isTTY = opts.isTTY ?? process.stderr.isTTY;
|
|
508
|
+
const errStream = { write: printErrFn, isTTY };
|
|
488
509
|
|
|
489
510
|
if (args.includes('--help') || args.includes('-h')) {
|
|
490
511
|
printFetchHelp();
|
|
@@ -597,12 +618,12 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
597
618
|
if (args[0] === 'compliance') {
|
|
598
619
|
const ticketKeyArg = args[1];
|
|
599
620
|
if (!ticketKeyArg) {
|
|
600
|
-
|
|
621
|
+
printErrFn('Error: "compliance" requires a ticket key. Usage: ticketlens compliance PROJ-123\n');
|
|
601
622
|
process.exitCode = 1;
|
|
602
623
|
return;
|
|
603
624
|
}
|
|
604
625
|
if (!TICKET_KEY_PATTERN.test(ticketKeyArg)) {
|
|
605
|
-
|
|
626
|
+
printErrFn(`Error: "${ticketKeyArg}" is not a valid ticket key. Expected format: PROJ-123\n`);
|
|
606
627
|
process.exitCode = 1;
|
|
607
628
|
return;
|
|
608
629
|
}
|
|
@@ -626,13 +647,13 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
626
647
|
configDir: resolvedConfigDir,
|
|
627
648
|
profileName: profileNameC,
|
|
628
649
|
cwd: process.cwd(),
|
|
629
|
-
onWarning: (w) =>
|
|
650
|
+
onWarning: (w) => printErrFn(w + '\n'),
|
|
630
651
|
onProfileNotFound: () => {},
|
|
631
652
|
});
|
|
632
653
|
|
|
633
654
|
const hasAuthC = connC.pat || (connC.email && connC.apiToken);
|
|
634
655
|
if (!connC.baseUrl || !hasAuthC) {
|
|
635
|
-
|
|
656
|
+
printErrFn('Error: No Jira credentials found. Run \'ticketlens init\' or set JIRA_BASE_URL + JIRA_API_TOKEN.\n');
|
|
636
657
|
process.exitCode = 1;
|
|
637
658
|
return;
|
|
638
659
|
}
|
|
@@ -643,7 +664,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
643
664
|
try {
|
|
644
665
|
ticketC = await adapterC.fetchTicket(ticketKeyArg, { depth: 0 });
|
|
645
666
|
} catch (err) {
|
|
646
|
-
|
|
667
|
+
printErrFn(`Error fetching ${ticketKeyArg}: ${err.message}\n`);
|
|
647
668
|
process.exitCode = 1;
|
|
648
669
|
return;
|
|
649
670
|
}
|
|
@@ -658,6 +679,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
658
679
|
description: ticketC.description,
|
|
659
680
|
ticketKey: ticketKeyArg,
|
|
660
681
|
configDir: resolvedConfigDir,
|
|
682
|
+
stream: errStream,
|
|
661
683
|
});
|
|
662
684
|
|
|
663
685
|
if (complianceResult === null) {
|
|
@@ -969,12 +991,12 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
969
991
|
|
|
970
992
|
const ticketKey = args.find(a => !a.startsWith('--'));
|
|
971
993
|
if (!ticketKey) {
|
|
972
|
-
printFetchHelp({ stream:
|
|
994
|
+
printFetchHelp({ stream: errStream });
|
|
973
995
|
process.exitCode = 1;
|
|
974
996
|
return;
|
|
975
997
|
}
|
|
976
998
|
if (!TICKET_KEY_PATTERN.test(ticketKey)) {
|
|
977
|
-
|
|
999
|
+
printErrFn(`Error: "${ticketKey}" is not a valid ticket key. Expected format: PROJ-123\n`);
|
|
978
1000
|
process.exitCode = 1;
|
|
979
1001
|
return;
|
|
980
1002
|
}
|
|
@@ -982,7 +1004,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
982
1004
|
// Normalize --project= alias once at entry so all recursive calls only see --profile=
|
|
983
1005
|
const projectArg = args.find(a => a.startsWith('--project='));
|
|
984
1006
|
if (projectArg) {
|
|
985
|
-
|
|
1007
|
+
printErrFn(`Hint: --project recognized as alias for --profile=${projectArg.split('=')[1]}\n\n`);
|
|
986
1008
|
args = args.map(a => a.startsWith('--project=') ? `--profile=${a.split('=')[1]}` : a);
|
|
987
1009
|
}
|
|
988
1010
|
|
|
@@ -992,7 +1014,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
992
1014
|
const validatedArgs = await handleUnknownFlags(
|
|
993
1015
|
args,
|
|
994
1016
|
['--help', '-h', '--plain', '--styled', '--no-attachments', '--no-cache', '--profile=', '--depth=', '--check', '--summarize', '--cloud', '--compliance', '--budget=', '--handoff', '--provider=', '--template='],
|
|
995
|
-
{ hints: ['--stale=', '--status=', '--static'] } // triage-only flags — shown as hints, not applied
|
|
1017
|
+
{ hints: ['--stale=', '--status=', '--static'], stream: errStream } // triage-only flags — shown as hints, not applied
|
|
996
1018
|
);
|
|
997
1019
|
if (validatedArgs === null) { process.exitCode = 1; return; }
|
|
998
1020
|
args = validatedArgs;
|
|
@@ -1006,7 +1028,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1006
1028
|
const tpl = await resolveTemplate(templateSlug, { token: readCliToken(configDir), fetcher });
|
|
1007
1029
|
templateSections = tpl.sections;
|
|
1008
1030
|
} catch (err) {
|
|
1009
|
-
|
|
1031
|
+
printErrFn(`Error: ${err.message}\n`);
|
|
1010
1032
|
process.exitCode = 1;
|
|
1011
1033
|
return;
|
|
1012
1034
|
}
|
|
@@ -1021,10 +1043,11 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1021
1043
|
.filter(([, p]) => p.ticketPrefixes?.includes(prefix))
|
|
1022
1044
|
.map(([name, p]) => ({ name, baseUrl: p.baseUrl || null }));
|
|
1023
1045
|
if (multiMatches.length > 1) {
|
|
1024
|
-
const
|
|
1046
|
+
const promptMultipleMatchesFn = opts.promptMultipleMatchesFn ?? promptMultipleMatches;
|
|
1047
|
+
const picked = await promptMultipleMatchesFn(ticketKey, multiMatches, { stream: errStream });
|
|
1025
1048
|
if (!picked) { process.exitCode = 1; return; }
|
|
1026
1049
|
const withProfile = [...args.filter(a => !a.startsWith('--profile=')), `--profile=${picked}`];
|
|
1027
|
-
return run(withProfile,
|
|
1050
|
+
return run(withProfile, selfOpts);
|
|
1028
1051
|
}
|
|
1029
1052
|
}
|
|
1030
1053
|
|
|
@@ -1034,18 +1057,19 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1034
1057
|
configDir,
|
|
1035
1058
|
profileName,
|
|
1036
1059
|
cwd: process.cwd(),
|
|
1037
|
-
onWarning: (w) =>
|
|
1060
|
+
onWarning: (w) => printErrFn(w + '\n'),
|
|
1038
1061
|
onProfileNotFound: (info) => { profileError = info; },
|
|
1039
1062
|
});
|
|
1040
1063
|
|
|
1041
1064
|
const hasAuth = conn.pat || (conn.email && conn.apiToken);
|
|
1042
1065
|
if (!conn.baseUrl || !hasAuth) {
|
|
1043
1066
|
if (profileError) {
|
|
1044
|
-
const
|
|
1067
|
+
const promptProfileSelectFn = opts.promptProfileSelectFn ?? promptProfileSelect;
|
|
1068
|
+
const picked = await promptProfileSelectFn(profileError, { stream: errStream });
|
|
1045
1069
|
if (picked) {
|
|
1046
1070
|
const newArgs = args.filter(a => !a.startsWith('--profile='));
|
|
1047
1071
|
newArgs.push(`--profile=${picked}`);
|
|
1048
|
-
return run(newArgs,
|
|
1072
|
+
return run(newArgs, selfOpts);
|
|
1049
1073
|
}
|
|
1050
1074
|
} else {
|
|
1051
1075
|
const missing = [];
|
|
@@ -1056,7 +1080,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1056
1080
|
: `Missing config in profile "${conn.profileName}": ${missing.join(', ')}`;
|
|
1057
1081
|
const noProfiles = !loadProfiles(configDir)?.profiles;
|
|
1058
1082
|
const initHint = noProfiles ? '\nRun `ticketlens init` to set up your connection.' : '';
|
|
1059
|
-
|
|
1083
|
+
printErrFn(`Error: ${hint}${initHint}\n`);
|
|
1060
1084
|
}
|
|
1061
1085
|
process.exitCode = 1;
|
|
1062
1086
|
return;
|
|
@@ -1072,10 +1096,11 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1072
1096
|
const allProfiles = Object.entries(config?.profiles ?? {})
|
|
1073
1097
|
.map(([name, p]) => ({ name, baseUrl: p.baseUrl || null }));
|
|
1074
1098
|
if (allProfiles.length > 1) {
|
|
1075
|
-
const
|
|
1099
|
+
const promptProfileMismatchFn = opts.promptProfileMismatchFn ?? promptProfileMismatch;
|
|
1100
|
+
const picked = await promptProfileMismatchFn(ticketKey, conn.profileName, allProfiles, { stream: errStream });
|
|
1076
1101
|
if (picked && picked !== conn.profileName) {
|
|
1077
1102
|
const withProfile = [...args.filter(a => !a.startsWith('--profile=')), `--profile=${picked}`];
|
|
1078
|
-
return run(withProfile,
|
|
1103
|
+
return run(withProfile, selfOpts);
|
|
1079
1104
|
}
|
|
1080
1105
|
}
|
|
1081
1106
|
}
|
|
@@ -1113,7 +1138,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1113
1138
|
if (cached) {
|
|
1114
1139
|
const s = createStyler({ isTTY: process.stderr.isTTY });
|
|
1115
1140
|
const age = briefCacheAge(cached.fetchedAt);
|
|
1116
|
-
|
|
1141
|
+
printErrFn(` ${s.dim('○')} ${s.dim(`${ticketKey} · from cache (${age}) · --no-cache to refresh`)}\n\n`);
|
|
1117
1142
|
|
|
1118
1143
|
const allText = [cached.ticket.description, ...cached.ticket.comments.map(c => c.body)].filter(Boolean).join('\n');
|
|
1119
1144
|
const codeRefs = extractCodeReferences(allText);
|
|
@@ -1177,7 +1202,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1177
1202
|
}
|
|
1178
1203
|
// ──────────────────────────────────────────────────────────────────────────
|
|
1179
1204
|
|
|
1180
|
-
const session = createSession(conn);
|
|
1205
|
+
const session = createSession(conn, { stream: errStream });
|
|
1181
1206
|
|
|
1182
1207
|
// Load all profiles once for use in the switch-profile retry option.
|
|
1183
1208
|
const allProfiles = Object.entries(loadProfiles(configDir)?.profiles ?? {})
|
|
@@ -1212,23 +1237,28 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1212
1237
|
}
|
|
1213
1238
|
|
|
1214
1239
|
if (RETRY_OPTIONS[retryIndex].value === 'switch') {
|
|
1215
|
-
const
|
|
1240
|
+
const promptSwitchProfileFn = opts.promptSwitchProfileFn ?? promptSwitchProfile;
|
|
1241
|
+
const picked = await promptSwitchProfileFn(conn.profileName, allProfiles, { stream: errStream });
|
|
1216
1242
|
if (picked && picked !== conn.profileName) {
|
|
1217
1243
|
const withProfile = [...args.filter(a => !a.startsWith('--profile=')), `--profile=${picked}`];
|
|
1218
|
-
return run(withProfile,
|
|
1244
|
+
return run(withProfile, selfOpts);
|
|
1219
1245
|
}
|
|
1220
1246
|
// Cancelled switch — exit
|
|
1221
1247
|
process.exitCode = 1;
|
|
1222
1248
|
return;
|
|
1223
1249
|
}
|
|
1224
1250
|
|
|
1225
|
-
// 'retry' — loop with updated spinner message
|
|
1251
|
+
// 'retry' — loop with updated spinner message. This whole retry-prompt
|
|
1252
|
+
// branch only runs when `!process.stderr.isTTY || !process.stdin.setRawMode`
|
|
1253
|
+
// is false — i.e. never under the MCP server (stdin is always piped there) —
|
|
1254
|
+
// so its remaining process.stderr.write calls are intentionally left
|
|
1255
|
+
// unconverted, dead code under MCP by construction, not an oversight.
|
|
1226
1256
|
isRetry = true;
|
|
1227
1257
|
process.stderr.write('\n');
|
|
1228
1258
|
}
|
|
1229
1259
|
}
|
|
1230
1260
|
session.connected();
|
|
1231
|
-
|
|
1261
|
+
printErrFn('\n');
|
|
1232
1262
|
|
|
1233
1263
|
// ── Confluence page fetching ───────────────────────────────────────────────
|
|
1234
1264
|
// Fetch pages referenced via Jira Remote Links (Confluence-only, non-blocking).
|
|
@@ -1276,7 +1306,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1276
1306
|
requirements: extractRequirements(desc),
|
|
1277
1307
|
};
|
|
1278
1308
|
const result = dtm.detectDrift(current, prior);
|
|
1279
|
-
if (result.drifted)
|
|
1309
|
+
if (result.drifted) printErrFn(dtm.formatDriftWarning(ticketKey, result.changes));
|
|
1280
1310
|
}
|
|
1281
1311
|
dtm.writeSnapshot(ticketKey, ticket, { profile: profileName, configDir: resolvedConfigDir, branch });
|
|
1282
1312
|
}
|
|
@@ -1287,14 +1317,14 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1287
1317
|
const downloadable = (ticket.attachments ?? []).filter(a => a.content);
|
|
1288
1318
|
if (downloadable.length > 0) {
|
|
1289
1319
|
const noun = downloadable.length === 1 ? 'attachment' : 'attachments';
|
|
1290
|
-
|
|
1320
|
+
printErrFn(`Downloading ${downloadable.length} ${noun}…\n`);
|
|
1291
1321
|
// attachment-downloader is Jira-specific — it needs raw auth headers from buildJiraEnv
|
|
1292
1322
|
const jiraEnv = buildJiraEnv(conn);
|
|
1293
1323
|
ticket.localAttachments = await downloadAttachments(ticket, {
|
|
1294
1324
|
env: jiraEnv,
|
|
1295
1325
|
fetcher,
|
|
1296
1326
|
noCache: args.includes('--no-cache'),
|
|
1297
|
-
onProgress: (msg) =>
|
|
1327
|
+
onProgress: (msg) => printErrFn(msg + '\n'),
|
|
1298
1328
|
allowPrivateIp: conn.allowPrivateIp,
|
|
1299
1329
|
});
|
|
1300
1330
|
const downloaded = ticket.localAttachments.filter(r => !r.skipped).length;
|
|
@@ -1302,8 +1332,8 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1302
1332
|
const parts = [];
|
|
1303
1333
|
if (downloaded > 0) parts.push(`${downloaded} downloaded`);
|
|
1304
1334
|
if (cached > 0) parts.push(`${cached} cached`);
|
|
1305
|
-
if (parts.length > 0)
|
|
1306
|
-
|
|
1335
|
+
if (parts.length > 0) printErrFn(` ✓ ${parts.join(', ')}\n`);
|
|
1336
|
+
printErrFn('\n');
|
|
1307
1337
|
}
|
|
1308
1338
|
}
|
|
1309
1339
|
|
|
@@ -1369,7 +1399,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1369
1399
|
// Contextual upsell: after a deep traversal with a substantial graph, nudge toward --summarize
|
|
1370
1400
|
if (depth > 1 && !args.includes('--summarize') && (ticket.linked?.length ?? 0) >= 2) {
|
|
1371
1401
|
const s = createStyler({ isTTY: process.stderr.isTTY });
|
|
1372
|
-
|
|
1402
|
+
printErrFn(` ${s.dim('○')} ${s.dim('Tip: large briefs compress further — `--summarize` condenses this to a single AI digest ($8/mo)')}\n`);
|
|
1373
1403
|
}
|
|
1374
1404
|
}
|
|
1375
1405
|
|
|
@@ -642,14 +642,16 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
|
642
642
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
643
643
|
const lines = [
|
|
644
644
|
'',
|
|
645
|
-
` ${s.bold(s.brand('ticketlens'))} ${s.bold('mcp')}
|
|
645
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('mcp')}`,
|
|
646
646
|
'',
|
|
647
|
-
` Start an MCP (Model Context Protocol) stdio server exposing
|
|
648
|
-
`
|
|
647
|
+
` Start an MCP (Model Context Protocol) stdio server exposing CLI actions as`,
|
|
648
|
+
` native tools — ${s.cyan('fetch')}, ${s.cyan('triage')}, ${s.cyan('compliance')}, ${s.cyan('doctor')}, ${s.cyan('recall_add')}, ${s.cyan('recall_search')}, ${s.cyan('ticket_comment')},`,
|
|
649
649
|
` ${s.cyan('ticket_transition')}, ${s.cyan('ticket_assign')}, ${s.cyan('ticket_duplicates')}, ${s.cyan('ticket_link')}, ${s.cyan('ticket_update')}, ${s.cyan('ticket_create')} — for any`,
|
|
650
650
|
` MCP-compatible AI harness, not just Claude Code. Thin adapter over the same`,
|
|
651
|
-
` code as ${s.cyan('note add')}/${s.cyan('recall')}/${s.cyan('comment')}/${s.cyan('transition')}/${s.cyan('assign')}/${s.cyan('duplicates')}/${s.cyan('link')}/${s.cyan('update')}/${s.cyan('create')}
|
|
652
|
-
`
|
|
651
|
+
` code as ${s.cyan('TICKET-KEY')}/${s.cyan('doctor')}/${s.cyan('triage')}/${s.cyan('compliance')}/${s.cyan('note add')}/${s.cyan('recall')}/${s.cyan('comment')}/${s.cyan('transition')}/${s.cyan('assign')}/${s.cyan('duplicates')}/${s.cyan('link')}/${s.cyan('update')}/${s.cyan('create')}`,
|
|
652
|
+
` above. ${s.cyan('fetch')} and ${s.cyan('doctor')} are Free; ${s.cyan('triage')} is Free with some Pro/Team-gated`,
|
|
653
|
+
` options (see ${s.cyan('ticketlens triage --help')}); ${s.cyan('compliance')} is Free with a 3-checks/month cap,`,
|
|
654
|
+
` Pro unlimited; every other tool needs Pro. ${s.cyan('ticket_transition')} is`,
|
|
653
655
|
` destructive when called with \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')} is currently`,
|
|
654
656
|
` self-assign only; ${s.cyan('ticket_duplicates')} is read-only; ${s.cyan('ticket_link')} on GitHub closes the`,
|
|
655
657
|
` source issue as a duplicate — different semantics than Jira/Linear's relationship-only`,
|
|
@@ -663,9 +665,8 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
|
663
665
|
` ${s.dim('restart or reconnect the MCP client to pick up the new schema. The running')}`,
|
|
664
666
|
` ${s.dim('server is always current; only the client-side copy goes stale.')}`,
|
|
665
667
|
'',
|
|
666
|
-
` ${s.dim('
|
|
667
|
-
` ${s.dim('
|
|
668
|
-
` ${s.dim('own license check at call time.')}`,
|
|
668
|
+
` ${s.dim('Starting the server itself is ungated, same as `mcp install`; each tool still')}`,
|
|
669
|
+
` ${s.dim('enforces its own license check at call time.')}`,
|
|
669
670
|
'',
|
|
670
671
|
` ${s.bold('OPTIONS')}`,
|
|
671
672
|
'',
|
|
@@ -25,10 +25,59 @@ import { runDoctor } from './doctor-command.mjs';
|
|
|
25
25
|
import { runNoteAdd } from './note-command.mjs';
|
|
26
26
|
import { runRecall } from './recall-command.mjs';
|
|
27
27
|
import { runTicketComment, runTicketTransitionList, runTicketTransition, runTicketAssign, runTicketDuplicates, runTicketLinkList, runTicketLink, runTicketUpdate, runTicketCreate } from './ticket-command.mjs';
|
|
28
|
+
import { run as runFetchTicket } from '../fetch-ticket.mjs';
|
|
29
|
+
import { run as runTriage } from '../fetch-my-tickets.mjs';
|
|
28
30
|
|
|
29
31
|
const PROTOCOL_VERSION = '2025-11-25';
|
|
30
32
|
|
|
31
33
|
const TOOLS = [
|
|
34
|
+
{
|
|
35
|
+
name: 'fetch',
|
|
36
|
+
description: 'Fetch a ticket\'s full context brief (Jira/GitHub/Linear) — description, comments, linked tickets, code references, attachments. The core read action; free tier. Not a discovery tool — requires a known ticket key.',
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {
|
|
40
|
+
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
41
|
+
profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
|
|
42
|
+
depth: { type: 'number', description: 'How many hops of linked tickets to traverse. Defaults to 1 (direct links only). 0 disables traversal.' },
|
|
43
|
+
},
|
|
44
|
+
required: ['ticket'],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'triage',
|
|
49
|
+
description: 'Scan assigned tickets and surface what needs attention — replies owed, aging tickets, stale-status tickets. The base scan is free tier; some options require a TicketLens Pro or Team license.',
|
|
50
|
+
inputSchema: {
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: {
|
|
53
|
+
profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
|
|
54
|
+
stale: { type: 'number', description: 'Days before an untouched ticket counts as aging. Defaults to 5.' },
|
|
55
|
+
status: { type: 'array', items: { type: 'string' }, description: 'Statuses to include, overriding the profile default / built-in defaults (In Progress, Code Review, QA).' },
|
|
56
|
+
sort: { type: 'string', description: 'Sort order for results, overriding the profile default.' },
|
|
57
|
+
save: { type: 'string', description: 'Write the plain-text summary to this local file path instead of (in addition to) returning it. Requires a TicketLens Pro license.' },
|
|
58
|
+
all: { type: 'boolean', description: 'Triage every configured profile, not just the resolved one. Requires a TicketLens Pro license.' },
|
|
59
|
+
digest: { type: 'boolean', description: 'Deliver the scored results to the digest backend instead of returning them as text — on success, no summary is returned, only a delivery confirmation. Requires a TicketLens Pro license.' },
|
|
60
|
+
assignee: { type: 'string', description: 'View another user\'s tickets instead of your own. Requires a TicketLens Team license.' },
|
|
61
|
+
sprint: { type: 'string', description: 'Scope to a named sprint. Requires a TicketLens Team license.' },
|
|
62
|
+
export: { type: 'string', enum: ['csv', 'json'], description: 'Write results to a file in this format instead of returning the summary text, returning the written file path instead. Requires a TicketLens Team license.' },
|
|
63
|
+
project: { type: 'string', description: 'Scope to a project/team key. Requires a TicketLens Team license.' },
|
|
64
|
+
label: { type: 'array', items: { type: 'string' }, description: 'Scope to one or more labels. Requires a TicketLens Team license.' },
|
|
65
|
+
priority: { type: 'string', description: 'Scope to a priority name, e.g. "High". Requires a TicketLens Team license.' },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'compliance',
|
|
71
|
+
description: 'Check a ticket\'s acceptance-criteria coverage against the current git diff — extracts requirements from the ticket description, matches them against code changes, and reports a coverage percentage plus what\'s missing. Read-only; the same check `ticketlens install-hooks` runs automatically. Free tier: 3 checks per month; TicketLens Pro removes the limit.',
|
|
72
|
+
inputSchema: {
|
|
73
|
+
type: 'object',
|
|
74
|
+
properties: {
|
|
75
|
+
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
76
|
+
profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
|
|
77
|
+
},
|
|
78
|
+
required: ['ticket'],
|
|
79
|
+
},
|
|
80
|
+
},
|
|
32
81
|
{
|
|
33
82
|
name: 'doctor',
|
|
34
83
|
description: 'Diagnose common TicketLens problems: profile configuration, license freshness, tracker connectivity, attachment cache health, MCP registration, and the Recall sync queue. Always returns structured JSON. Free tier, fully unrestricted — including fix.',
|
|
@@ -180,6 +229,148 @@ function capturingStream() {
|
|
|
180
229
|
};
|
|
181
230
|
}
|
|
182
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Deliberately v1-minimal — only the three flags with zero cost/AI-provider
|
|
234
|
+
* implications (see the 49b scoping decision). If `--summarize`/`--handoff`/
|
|
235
|
+
* `--budget=`/`--compliance`/`--template=` are ever added here, note that
|
|
236
|
+
* their error/progress output inside fetch-ticket.mjs's bare-fetch path
|
|
237
|
+
* (applySummarize/applyHandoff/budgetPruner.pruneBrief/showUpgradePrompt)
|
|
238
|
+
* still writes to the real process.stderr, not the injected printErr —
|
|
239
|
+
* unlike every path reachable through this function today. Thread printErr
|
|
240
|
+
* through those call sites first, or their failures will silently collapse
|
|
241
|
+
* to callFetch's generic 'fetch failed' instead of the real reason.
|
|
242
|
+
*/
|
|
243
|
+
function buildFetchArgs({ ticket, profile, depth }) {
|
|
244
|
+
const args = [ticket];
|
|
245
|
+
if (profile) args.push(`--profile=${profile}`);
|
|
246
|
+
if (depth !== undefined) args.push(`--depth=${depth}`);
|
|
247
|
+
return args;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Shared by every dispatch that goes through fetch-ticket.mjs's `run()`
|
|
252
|
+
* (currently `fetch` and `compliance` — `compliance` is just another
|
|
253
|
+
* subcommand of that same function). `run()` has no {ok} return value
|
|
254
|
+
* (unlike every other wrapped function) — failure is signaled by mutating
|
|
255
|
+
* process.exitCode, which is unsafe to read in a long-lived server (one
|
|
256
|
+
* failed call would poison the whole process's exit code forever). Success
|
|
257
|
+
* is instead determined by whether `print` ever received real content —
|
|
258
|
+
* every success path (cache hit, fresh fetch, handoff, a printed report
|
|
259
|
+
* regardless of pass/fail) calls it exactly once; every failure path
|
|
260
|
+
* returns before reaching it. errCapture may contain informational
|
|
261
|
+
* chatter (cache notice, download progress) even on success — only read
|
|
262
|
+
* on the failure branch, where it carries the actual error message.
|
|
263
|
+
*/
|
|
264
|
+
async function callFetchTicketRun(buildArgsFn, args, { configDir, runFetchTicketFn }, fallbackErrorText) {
|
|
265
|
+
const capture = capturingStream();
|
|
266
|
+
const errCapture = capturingStream();
|
|
267
|
+
await runFetchTicketFn(buildArgsFn(args), {
|
|
268
|
+
configDir,
|
|
269
|
+
env: process.env,
|
|
270
|
+
fetcher: globalThis.fetch,
|
|
271
|
+
print: capture.write,
|
|
272
|
+
printErr: errCapture.write,
|
|
273
|
+
});
|
|
274
|
+
if (capture.text) {
|
|
275
|
+
return { content: [{ type: 'text', text: capture.text }] };
|
|
276
|
+
}
|
|
277
|
+
return { isError: true, content: [{ type: 'text', text: errCapture.text || fallbackErrorText }] };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function callFetch(args, deps) {
|
|
281
|
+
if (!args.ticket) {
|
|
282
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
|
|
283
|
+
}
|
|
284
|
+
return callFetchTicketRun(buildFetchArgs, args, deps, 'fetch failed');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Deliberately excludes --push/--share — those sync/share a snapshot as a
|
|
289
|
+
* human-collaboration side effect (Console notification queue, shareable
|
|
290
|
+
* link), not "what needs attention" read value. See the 49b scoping memory.
|
|
291
|
+
*/
|
|
292
|
+
function buildTriageArgs({ profile, stale, status, sort, save, all, digest, assignee, sprint, export: exportFormat, project, label, priority }) {
|
|
293
|
+
const args = ['--plain'];
|
|
294
|
+
if (profile) args.push(`--profile=${profile}`);
|
|
295
|
+
if (stale !== undefined) args.push(`--stale=${stale}`);
|
|
296
|
+
if (Array.isArray(status) && status.length > 0) args.push(`--status=${status.join(',')}`);
|
|
297
|
+
if (sort) args.push(`--sort=${sort}`);
|
|
298
|
+
if (save) args.push(`--save=${save}`);
|
|
299
|
+
if (all === true) args.push('--all');
|
|
300
|
+
if (digest === true) args.push('--digest');
|
|
301
|
+
if (assignee) args.push(`--assignee=${assignee}`);
|
|
302
|
+
if (sprint) args.push(`--sprint=${sprint}`);
|
|
303
|
+
if (exportFormat) args.push(`--export=${exportFormat}`);
|
|
304
|
+
if (project) args.push(`--project=${project}`);
|
|
305
|
+
if (Array.isArray(label) && label.length > 0) args.push(`--label=${label.join(',')}`);
|
|
306
|
+
if (priority) args.push(`--priority=${priority}`);
|
|
307
|
+
return args;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* runTriage has the same no-{ok}-return architecture as runFetchTicket —
|
|
312
|
+
* success is "did print receive the summary," failure is whatever landed in
|
|
313
|
+
* the injected stream. One deliberate exception: `--digest` delivers to the
|
|
314
|
+
* backend and prints NOTHING to `print` on success (locked by
|
|
315
|
+
* fetch-my-tickets.test.mjs's own "stdout should be empty" test) — an empty
|
|
316
|
+
* capture there means delivery succeeded, not that it failed. Only a gate
|
|
317
|
+
* rejection (Pro license, captured in `stream`) or a thrown delivery error
|
|
318
|
+
* (caught below) signal an actual digest failure.
|
|
319
|
+
*/
|
|
320
|
+
async function callTriage(args, { configDir, runTriageFn }) {
|
|
321
|
+
const capture = capturingStream();
|
|
322
|
+
const errCapture = capturingStream();
|
|
323
|
+
try {
|
|
324
|
+
await runTriageFn(buildTriageArgs(args), {
|
|
325
|
+
configDir,
|
|
326
|
+
env: process.env,
|
|
327
|
+
fetcher: globalThis.fetch,
|
|
328
|
+
print: capture.write,
|
|
329
|
+
stream: errCapture,
|
|
330
|
+
});
|
|
331
|
+
} catch (err) {
|
|
332
|
+
return { isError: true, content: [{ type: 'text', text: err.message }] };
|
|
333
|
+
}
|
|
334
|
+
if (args.digest === true) {
|
|
335
|
+
if (errCapture.text) {
|
|
336
|
+
return { isError: true, content: [{ type: 'text', text: errCapture.text }] };
|
|
337
|
+
}
|
|
338
|
+
return { content: [{ type: 'text', text: 'Digest delivered.' }] };
|
|
339
|
+
}
|
|
340
|
+
if (capture.text) {
|
|
341
|
+
return { content: [{ type: 'text', text: capture.text }] };
|
|
342
|
+
}
|
|
343
|
+
return { isError: true, content: [{ type: 'text', text: errCapture.text || 'triage failed' }] };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* `compliance` is a subcommand of the same fetch-ticket.mjs `run()` that
|
|
348
|
+
* `callFetch` already wraps — reuses `runFetchTicketFn`, no new dependency
|
|
349
|
+
* or import. See fetch-ticket.mjs's `compliance` dispatch block (thread
|
|
350
|
+
* printErr through it before this tool existed — see the fetch tool's own
|
|
351
|
+
* shipping notes for why that treatment was deferred per-tool).
|
|
352
|
+
*/
|
|
353
|
+
function buildComplianceArgs({ ticket, profile }) {
|
|
354
|
+
const args = ['compliance', ticket];
|
|
355
|
+
if (profile) args.push(`--profile=${profile}`);
|
|
356
|
+
return args;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Uses the shared callFetchTicketRun — a below-threshold result is still a
|
|
361
|
+
* successful check: `run()` prints the report (via `printFn`) before
|
|
362
|
+
* evaluating the threshold, so a failing coverage percentage is real,
|
|
363
|
+
* useful report content, not a tool failure. Only the license/usage-gate
|
|
364
|
+
* case (`runComplianceCheck` returns null) skips the report print entirely
|
|
365
|
+
* — that's the one path that surfaces as `isError`.
|
|
366
|
+
*/
|
|
367
|
+
async function callCompliance(args, deps) {
|
|
368
|
+
if (!args.ticket) {
|
|
369
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
|
|
370
|
+
}
|
|
371
|
+
return callFetchTicketRun(buildComplianceArgs, args, deps, 'compliance check failed');
|
|
372
|
+
}
|
|
373
|
+
|
|
183
374
|
function buildDoctorArgs({ fix, profile }) {
|
|
184
375
|
const args = ['--format=json'];
|
|
185
376
|
if (fix === true) args.push('--fix');
|
|
@@ -403,6 +594,9 @@ async function callTicketCreate(args, { configDir, runTicketCreateFn }) {
|
|
|
403
594
|
|
|
404
595
|
async function handleToolsCall(params, deps) {
|
|
405
596
|
const { name, arguments: args = {} } = params ?? {};
|
|
597
|
+
if (name === 'fetch') return callFetch(args, deps);
|
|
598
|
+
if (name === 'triage') return callTriage(args, deps);
|
|
599
|
+
if (name === 'compliance') return callCompliance(args, deps);
|
|
406
600
|
if (name === 'doctor') return callDoctor(args, deps);
|
|
407
601
|
if (name === 'recall_add') return callRecallAdd(args, deps);
|
|
408
602
|
if (name === 'recall_search') return callRecallSearch(args, deps);
|
|
@@ -416,7 +610,7 @@ async function handleToolsCall(params, deps) {
|
|
|
416
610
|
return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
|
|
417
611
|
}
|
|
418
612
|
|
|
419
|
-
async function handleMessage(raw, { configDir, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn }) {
|
|
613
|
+
async function handleMessage(raw, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn }) {
|
|
420
614
|
let msg;
|
|
421
615
|
try {
|
|
422
616
|
msg = JSON.parse(raw);
|
|
@@ -446,7 +640,7 @@ async function handleMessage(raw, { configDir, runDoctorFn, runNoteAddFn, runRec
|
|
|
446
640
|
|
|
447
641
|
if (method === 'tools/call') {
|
|
448
642
|
try {
|
|
449
|
-
const result = await handleToolsCall(params, { configDir, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
|
|
643
|
+
const result = await handleToolsCall(params, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
|
|
450
644
|
return jsonRpcResult(id, result);
|
|
451
645
|
} catch (err) {
|
|
452
646
|
return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
|
|
@@ -467,6 +661,8 @@ export function runMcpServer({
|
|
|
467
661
|
configDir = DEFAULT_CONFIG_DIR,
|
|
468
662
|
stdin = process.stdin,
|
|
469
663
|
stdout = process.stdout,
|
|
664
|
+
runFetchTicketFn = runFetchTicket,
|
|
665
|
+
runTriageFn = runTriage,
|
|
470
666
|
runDoctorFn = runDoctor,
|
|
471
667
|
runNoteAddFn = runNoteAdd,
|
|
472
668
|
runRecallFn = runRecall,
|
|
@@ -498,7 +694,7 @@ export function runMcpServer({
|
|
|
498
694
|
// never resolving (a dropped rejection isn't a resolution) — the
|
|
499
695
|
// server would hang on shutdown instead of exiting.
|
|
500
696
|
queue = queue.then(async () => {
|
|
501
|
-
const response = await handleMessage(line, { configDir, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
|
|
697
|
+
const response = await handleMessage(line, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
|
|
502
698
|
if (response) stdout.write(response);
|
|
503
699
|
}).catch(() => {});
|
|
504
700
|
});
|