ticketlens 0.21.12 → 0.22.1
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 +4 -0
- package/bin/ticketlens.mjs +10 -1
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +3 -1
- package/skills/jtb/scripts/lib/cli.mjs +4 -0
- package/skills/jtb/scripts/lib/help.mjs +29 -0
- package/skills/jtb/scripts/lib/mcp-server.mjs +202 -0
- package/skills/jtb/scripts/lib/recall-sync.mjs +1 -1
package/README.md
CHANGED
|
@@ -411,6 +411,8 @@ Every note is scanned before saving — anything shaped like a real secret (API
|
|
|
411
411
|
|
|
412
412
|
**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).
|
|
413
413
|
|
|
414
|
+
**Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `recall_add` and `recall_search` 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 Pro gate, same secret scan, same local vault, same team sync — nothing is reimplemented. Point your harness's MCP config at it: `{ "command": "ticketlens", "args": ["mcp"] }`.
|
|
415
|
+
|
|
414
416
|
`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.
|
|
415
417
|
|
|
416
418
|
**Gaps** — every `ticketlens PROJ-123` brief also diffs the ticket's own description against its linked tickets (from the depth traversal you already requested) and its own downloaded attachments, looking for requirements mentioned there but missing here. Anything uncovered shows up under a `## Gaps` section, citing exactly where it came from — a linked ticket key or an attachment filename — as evidence, never an instruction to act on. Nothing is saved anywhere; it's recomputed fresh on every fetch. Requires a Pro license, same as Recall. No network call beyond what the brief already made.
|
|
@@ -690,6 +692,7 @@ ticketlens recall CNV1-2 # Search saved notes by ticket key
|
|
|
690
692
|
ticketlens recall "retry backoff" # Free-text search across all notes [Pro]
|
|
691
693
|
ticketlens recall sync # Retry any notes stuck in the local queue [Team+]
|
|
692
694
|
ticketlens recall settings # Show effective retry-queue settings, fetched live [Team+]
|
|
695
|
+
ticketlens mcp # Start the MCP stdio server (recall_add/recall_search tools) [Pro]
|
|
693
696
|
|
|
694
697
|
# ── Stats ──────────────────────────────────────────────────────────────────────
|
|
695
698
|
ticketlens stats # Response-time metrics from local history
|
|
@@ -771,6 +774,7 @@ ticketlens note delete --id="..." # Remove a note from your local vault
|
|
|
771
774
|
ticketlens recall <query|TICKET-KEY> # Search your saved Recall notes
|
|
772
775
|
ticketlens recall sync # Retry any notes stuck in the local queue
|
|
773
776
|
ticketlens recall settings # Show effective retry-queue settings, fetched live
|
|
777
|
+
ticketlens mcp # Start the MCP stdio server (recall_add/recall_search)
|
|
774
778
|
ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
|
|
775
779
|
```
|
|
776
780
|
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
printComplianceHelp, printLedgerHelp, printPrHelp, printInstallHooksHelp,
|
|
28
28
|
printCollisionsHelp, printStatsHelp,
|
|
29
29
|
printCloudKeysHelp,
|
|
30
|
-
printNoteHelp, printRecallHelp,
|
|
30
|
+
printNoteHelp, printRecallHelp, printMcpHelp,
|
|
31
31
|
} from '../skills/jtb/scripts/lib/help.mjs';
|
|
32
32
|
import { runStats } from '../skills/jtb/scripts/lib/run-stats.mjs';
|
|
33
33
|
import { createStyler } from '../skills/jtb/scripts/lib/ansi.mjs';
|
|
@@ -722,6 +722,15 @@ switch (command) {
|
|
|
722
722
|
break;
|
|
723
723
|
}
|
|
724
724
|
|
|
725
|
+
case 'mcp': {
|
|
726
|
+
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printMcpHelp(); break; }
|
|
727
|
+
// Long-lived stdio server, not a one-shot command — no .then()-exit-code
|
|
728
|
+
// pattern; it exits when the client closes stdin (spec's stdio lifecycle).
|
|
729
|
+
const { runMcpServer } = await import('../skills/jtb/scripts/lib/mcp-server.mjs');
|
|
730
|
+
await runMcpServer({});
|
|
731
|
+
break;
|
|
732
|
+
}
|
|
733
|
+
|
|
725
734
|
case 'help':
|
|
726
735
|
default: {
|
|
727
736
|
const isInteractive = args.length === 0 && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI;
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.22.1 -->
|
|
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.
|
|
@@ -232,6 +232,8 @@ echo "The body text of the note, one or more paragraphs." | \
|
|
|
232
232
|
|
|
233
233
|
To search saved notes directly (outside of automatic brief injection): `ticketlens recall "<query>"`.
|
|
234
234
|
|
|
235
|
+
**Pick exactly one path per capture — never both.** If this harness has TicketLens's MCP server configured (`ticketlens mcp` — `recall_add`/`recall_search` as native tools, see `ticketlens mcp --help`), prefer calling those tools directly over the bash commands above — same license gate, same secret scan, same vault, same team sync, just no shell command to construct. Fall back to the bash form only when the MCP tools aren't available. Calling both for the same insight creates two near-duplicate notes (no dedup exists between the two paths) and, with team sync on, two separate pushes for a manager to review.
|
|
236
|
+
|
|
235
237
|
### Quality loop (Pro, in-session only)
|
|
236
238
|
|
|
237
239
|
Only when `note add` above was dispatched *by you, inside this skill*, and it printed a saved note id (e.g. `Saved note "Retry gotcha" (1784135399545-fe01c4.md)`) — never for a note a user typed directly into a bare shell, which has no Task/Agent tool available. If there's no such tool in your environment, skip this whole section silently: no warning, no degraded fallback, the note is already saved and that's a complete, correct outcome on its own.
|
|
@@ -126,6 +126,10 @@ export function parseCommand(args) {
|
|
|
126
126
|
return { command: 'recall', args: args.slice(1) };
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
if (first === 'mcp') {
|
|
130
|
+
return { command: 'mcp', args: args.slice(1) };
|
|
131
|
+
}
|
|
132
|
+
|
|
129
133
|
// Anything that looks like a ticket key or any non-flag arg → fetch
|
|
130
134
|
return { command: 'fetch', args };
|
|
131
135
|
}
|
|
@@ -53,6 +53,7 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
53
53
|
` ${s.brand('ticketlens')} recall ${s.dim('<query|TICKET-KEY>')} Search your saved Recall notes ${s.dim('[Pro]')}`,
|
|
54
54
|
` ${s.brand('ticketlens')} recall sync Retry any notes stuck in the local queue ${s.dim('[Team+]')}`,
|
|
55
55
|
` ${s.brand('ticketlens')} recall settings Show effective retry-queue settings, fetched live ${s.dim('[Team+]')}`,
|
|
56
|
+
` ${s.brand('ticketlens')} mcp Start the MCP stdio server for Recall ${s.dim('[Pro]')}`,
|
|
56
57
|
'',
|
|
57
58
|
` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
|
|
58
59
|
` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
|
|
@@ -585,6 +586,34 @@ export function printRecallHelp({ stream = process.stdout } = {}) {
|
|
|
585
586
|
stream.write(lines.join('\n') + '\n');
|
|
586
587
|
}
|
|
587
588
|
|
|
589
|
+
export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
590
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
591
|
+
const lines = [
|
|
592
|
+
'',
|
|
593
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('mcp')} ${s.dim('[Pro]')}`,
|
|
594
|
+
'',
|
|
595
|
+
` Start an MCP (Model Context Protocol) stdio server exposing Recall as`,
|
|
596
|
+
` native tools — ${s.cyan('recall_add')} and ${s.cyan('recall_search')} — for any MCP-compatible AI`,
|
|
597
|
+
` harness, not just Claude Code. Thin adapter over the same code as`,
|
|
598
|
+
` ${s.cyan('note add')}/${s.cyan('recall')} above: same Pro gate, same secret scan, same local`,
|
|
599
|
+
` vault, same team sync. Long-running — exits when the client closes stdin.`,
|
|
600
|
+
'',
|
|
601
|
+
` ${s.bold('OPTIONS')}`,
|
|
602
|
+
'',
|
|
603
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
604
|
+
'',
|
|
605
|
+
` ${s.bold('HARNESS CONFIG')}`,
|
|
606
|
+
'',
|
|
607
|
+
` ${s.dim('{ "command": "ticketlens", "args": ["mcp"] }')}`,
|
|
608
|
+
'',
|
|
609
|
+
` ${s.bold('EXAMPLES')}`,
|
|
610
|
+
'',
|
|
611
|
+
` ${s.dim('$')} ticketlens mcp`,
|
|
612
|
+
'',
|
|
613
|
+
];
|
|
614
|
+
stream.write(lines.join('\n') + '\n');
|
|
615
|
+
}
|
|
616
|
+
|
|
588
617
|
export function printSwitchHelp({ stream = process.stdout } = {}) {
|
|
589
618
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
590
619
|
const lines = [
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP (Model Context Protocol) stdio server — `ticketlens mcp`.
|
|
3
|
+
*
|
|
4
|
+
* A pure transport adapter: parses JSON-RPC 2.0 off stdin, translates
|
|
5
|
+
* `tools/call` arguments into the exact args/dependency shape `runNoteAdd`/
|
|
6
|
+
* `runRecall` already accept, and captures their human-readable `stream`
|
|
7
|
+
* output into the JSON-RPC response instead of a real stream. Zero new
|
|
8
|
+
* validation, licensing, or vault logic — both tools funnel through the
|
|
9
|
+
* same functions the CLI's `note add`/`recall` commands already use, so
|
|
10
|
+
* every existing gate (license, secret scan, structural check, retry
|
|
11
|
+
* queue) applies identically here.
|
|
12
|
+
*
|
|
13
|
+
* Per the MCP stdio transport spec, the server MUST NOT write anything to
|
|
14
|
+
* stdout that isn't a valid MCP message — every wrapped function's output
|
|
15
|
+
* is captured into a buffer and returned as the tool result, never piped
|
|
16
|
+
* to the real stdout that also carries the JSON-RPC channel.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import readline from 'node:readline';
|
|
20
|
+
import { DEFAULT_CONFIG_DIR, getVersion } from './config.mjs';
|
|
21
|
+
import { runNoteAdd } from './note-command.mjs';
|
|
22
|
+
import { runRecall } from './recall-command.mjs';
|
|
23
|
+
|
|
24
|
+
const PROTOCOL_VERSION = '2025-11-25';
|
|
25
|
+
|
|
26
|
+
const TOOLS = [
|
|
27
|
+
{
|
|
28
|
+
name: 'recall_add',
|
|
29
|
+
description: 'Save a Recall note — a gotcha, root cause, or non-obvious decision learned this session. Requires a TicketLens Pro license.',
|
|
30
|
+
inputSchema: {
|
|
31
|
+
type: 'object',
|
|
32
|
+
properties: {
|
|
33
|
+
title: { type: 'string', description: 'Short one-line title.' },
|
|
34
|
+
ticket: { type: 'string', description: 'Optional ticket key, e.g. PROJ-123.' },
|
|
35
|
+
tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags.' },
|
|
36
|
+
body: { type: 'string', description: 'The note body — one or more paragraphs.' },
|
|
37
|
+
},
|
|
38
|
+
required: ['title', 'body'],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'recall_search',
|
|
43
|
+
description: 'Search saved Recall notes by free-text query or ticket key. Requires a TicketLens Pro license.',
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
query: { type: 'string', description: 'Free-text query or a ticket key like PROJ-123.' },
|
|
48
|
+
},
|
|
49
|
+
required: ['query'],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
function jsonRpcResult(id, result) {
|
|
55
|
+
return JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function jsonRpcError(id, code, message) {
|
|
59
|
+
return JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }) + '\n';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Buffers stream.write() calls instead of touching a real stream. */
|
|
63
|
+
function capturingStream() {
|
|
64
|
+
const parts = [];
|
|
65
|
+
return {
|
|
66
|
+
write(s) { parts.push(s); return true; },
|
|
67
|
+
get text() { return parts.join(''); },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Builds runNoteAdd's cmdArgs array. Each `--flag=value` MUST stay a single,
|
|
73
|
+
* discrete array element — runNoteAdd's parseFlag matches per-element via
|
|
74
|
+
* startsWith/includes, which is only safe as long as this array is never
|
|
75
|
+
* joined into a string and re-split/re-tokenized. A title/body containing
|
|
76
|
+
* `--ticket=EVIL-999` stays inert precisely because it's never anything
|
|
77
|
+
* but one opaque array element.
|
|
78
|
+
*/
|
|
79
|
+
function buildNoteAddArgs({ title, ticket, tags }) {
|
|
80
|
+
const args = [`--title=${title}`];
|
|
81
|
+
if (ticket) args.push(`--ticket=${ticket}`);
|
|
82
|
+
if (Array.isArray(tags) && tags.length > 0) args.push(`--tags=${tags.join(',')}`);
|
|
83
|
+
return args;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function callRecallAdd(args, { configDir, runNoteAddFn }) {
|
|
87
|
+
// runNoteAdd's own `if (!rawTitle)` guard only rejects an empty string —
|
|
88
|
+
// `--title=${title}` with title===undefined template-stringifies to the
|
|
89
|
+
// truthy 4-char string "undefined", which would pass that guard and get
|
|
90
|
+
// persisted as a real note title. Reject before it ever reaches cmdArgs.
|
|
91
|
+
if (!args.title) {
|
|
92
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: title' }] };
|
|
93
|
+
}
|
|
94
|
+
const capture = capturingStream();
|
|
95
|
+
const { written } = await runNoteAddFn(buildNoteAddArgs(args), {
|
|
96
|
+
configDir,
|
|
97
|
+
stream: capture,
|
|
98
|
+
readStdin: async () => args.body ?? '',
|
|
99
|
+
});
|
|
100
|
+
const content = [{ type: 'text', text: capture.text }];
|
|
101
|
+
return written ? { content } : { isError: true, content };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function callRecallSearch(args, { configDir, runRecallFn }) {
|
|
105
|
+
const capture = capturingStream();
|
|
106
|
+
const { ok } = await runRecallFn([args.query ?? ''], {
|
|
107
|
+
configDir,
|
|
108
|
+
stream: capture,
|
|
109
|
+
errorStream: capture,
|
|
110
|
+
});
|
|
111
|
+
const content = [{ type: 'text', text: capture.text }];
|
|
112
|
+
return ok ? { content } : { isError: true, content };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function handleToolsCall(params, deps) {
|
|
116
|
+
const { name, arguments: args = {} } = params ?? {};
|
|
117
|
+
if (name === 'recall_add') return callRecallAdd(args, deps);
|
|
118
|
+
if (name === 'recall_search') return callRecallSearch(args, deps);
|
|
119
|
+
return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn }) {
|
|
123
|
+
let msg;
|
|
124
|
+
try {
|
|
125
|
+
msg = JSON.parse(raw);
|
|
126
|
+
} catch {
|
|
127
|
+
return jsonRpcError(null, -32700, 'Parse error');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (typeof msg !== 'object' || msg === null || Array.isArray(msg)) {
|
|
131
|
+
return jsonRpcError(null, -32600, 'Invalid Request');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const { id, method, params } = msg;
|
|
135
|
+
|
|
136
|
+
if (method === 'notifications/initialized') return null; // notification, no response
|
|
137
|
+
|
|
138
|
+
if (method === 'initialize') {
|
|
139
|
+
return jsonRpcResult(id, {
|
|
140
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
141
|
+
capabilities: { tools: {} },
|
|
142
|
+
serverInfo: { name: 'ticketlens', version: getVersion() },
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (method === 'tools/list') {
|
|
147
|
+
return jsonRpcResult(id, { tools: TOOLS });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (method === 'tools/call') {
|
|
151
|
+
try {
|
|
152
|
+
const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn });
|
|
153
|
+
return jsonRpcResult(id, result);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return jsonRpcError(id ?? null, -32601, `Method not found: ${method}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Runs the stdio JSON-RPC loop until stdin closes (the client closing
|
|
164
|
+
* stdin to end the session, per the stdio transport's lifecycle). Each
|
|
165
|
+
* line is fully processed — awaited — before the next is handled: a
|
|
166
|
+
* long-lived process (unlike the one-shot CLI) must not let a burst of
|
|
167
|
+
* rapid messages spawn unbounded concurrent tool calls.
|
|
168
|
+
*/
|
|
169
|
+
export function runMcpServer({
|
|
170
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
171
|
+
stdin = process.stdin,
|
|
172
|
+
stdout = process.stdout,
|
|
173
|
+
runNoteAddFn = runNoteAdd,
|
|
174
|
+
runRecallFn = runRecall,
|
|
175
|
+
} = {}) {
|
|
176
|
+
// A client can disconnect mid-write (EPIPE) at any time on a long-lived
|
|
177
|
+
// process — an unhandled 'error' event on either stream would otherwise
|
|
178
|
+
// throw and crash the whole server via Node's default EventEmitter
|
|
179
|
+
// behavior. Swallow here; the process ends naturally when stdin closes.
|
|
180
|
+
stdin.on('error', () => {});
|
|
181
|
+
stdout.on('error', () => {});
|
|
182
|
+
|
|
183
|
+
const rl = readline.createInterface({ input: stdin, terminal: false });
|
|
184
|
+
let queue = Promise.resolve();
|
|
185
|
+
|
|
186
|
+
rl.on('line', (line) => {
|
|
187
|
+
if (!line.trim()) return;
|
|
188
|
+
// .catch() here, not left to propagate: an unrejected chain would
|
|
189
|
+
// otherwise poison every later .then() forever (one bad line kills
|
|
190
|
+
// all subsequent messages) and leave `queue.then(resolve)` below
|
|
191
|
+
// never resolving (a dropped rejection isn't a resolution) — the
|
|
192
|
+
// server would hang on shutdown instead of exiting.
|
|
193
|
+
queue = queue.then(async () => {
|
|
194
|
+
const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn });
|
|
195
|
+
if (response) stdout.write(response);
|
|
196
|
+
}).catch(() => {});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
return new Promise((resolve) => {
|
|
200
|
+
rl.on('close', () => { queue.then(resolve); });
|
|
201
|
+
});
|
|
202
|
+
}
|
|
@@ -134,7 +134,7 @@ export async function pushNote(note, {
|
|
|
134
134
|
signal: AbortSignal.timeout(timeoutMs),
|
|
135
135
|
});
|
|
136
136
|
} catch {
|
|
137
|
-
warn(` ${yellow('⚠')} Could not sync note to your team (network error) — saved locally
|
|
137
|
+
warn(` ${yellow('⚠')} Could not sync note to your team (network error) — saved locally and queued to retry automatically.\n`);
|
|
138
138
|
return { ok: false };
|
|
139
139
|
}
|
|
140
140
|
|