ticketlens 0.38.32 → 0.38.34
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/bin/ticketlens.mjs +5 -22
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +41 -1
- package/skills/jtb/hooks/recall-nudge-lib.mjs +83 -4
- package/skills/jtb/hooks/recall-nudge-stop.mjs +20 -6
- package/skills/jtb/scripts/fetch-ticket.mjs +3 -1
- package/skills/jtb/scripts/lib/help.mjs +10 -6
- package/skills/jtb/scripts/lib/mcp-server.mjs +77 -220
- package/skills/jtb/scripts/lib/mcp-tool-schemas.mjs +257 -0
- package/skills/jtb/scripts/lib/run-history.mjs +50 -0
- package/skills/jtb/scripts/lib/secret-scanner.mjs +75 -14
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 `fetch`, `triage`, `compliance`, `review`, `standup`, `pr`, `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`, `doctor`, and `standup` are Free; `triage`'s base scan is Free with some options gated Pro/Team, same as the CLI (`ticketlens triage --help`); `compliance` and `pr` are Free, sharing a 3-checks/month cap on their requirements-coverage section, Pro unlimited; `review` is Free for branch/files/ticket context, with its coverage/focus section requiring Pro as a plain license check — it does not draw from that same monthly counter; 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).
|
|
456
|
+
**Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `fetch`, `triage`, `compliance`, `review`, `standup`, `pr`, `stats`, `history`, `collisions`, `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`, `doctor`, and `standup` are Free; `triage`'s base scan is Free with some options gated Pro/Team, same as the CLI (`ticketlens triage --help`); `compliance` and `pr` are Free, sharing a 3-checks/month cap on their requirements-coverage section, Pro unlimited; `review` is Free for branch/files/ticket context, with its coverage/focus section requiring Pro as a plain license check — it does not draw from that same monthly counter; `stats` is Free with a 7-day lookback cap, Pro extends it to 30 days, same split as the CLI (`ticketlens stats --help`); `history` reads local triage history only (zero network) and requires Pro; `collisions` requires `ticketlens login` (Console access) plus a Team license; 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/bin/ticketlens.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { deleteProfile, loadProfiles, saveCredentialKey, resolveRecallStrictness
|
|
|
20
20
|
import { run as runCache } from '../skills/jtb/scripts/lib/cache-manager.mjs';
|
|
21
21
|
import { runDoctor } from '../skills/jtb/scripts/lib/doctor-command.mjs';
|
|
22
22
|
import {
|
|
23
|
-
printHelp, printProfiles,
|
|
23
|
+
printHelp, printProfiles,
|
|
24
24
|
printLoginHelp, printLogoutHelp, printSyncHelp,
|
|
25
25
|
printActivateHelp, printLicenseHelp, printDeleteHelp,
|
|
26
26
|
printProfilesHelp, printScheduleHelp,
|
|
@@ -142,28 +142,11 @@ switch (command) {
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
case 'history': {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (!ticketKey || ticketKey.startsWith('-')) {
|
|
149
|
-
process.stderr.write('Usage: ticketlens history TICKET-KEY\n');
|
|
145
|
+
const { runHistory } = await import('../skills/jtb/scripts/lib/run-history.mjs');
|
|
146
|
+
runHistory(cmdArgs).catch(err => {
|
|
147
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
150
148
|
process.exitCode = 1;
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
const { queryTicketHistory } = await import('../skills/jtb/scripts/lib/triage-history.mjs');
|
|
154
|
-
const entries = queryTicketHistory(ticketKey);
|
|
155
|
-
if (entries.length === 0) {
|
|
156
|
-
process.stdout.write(`No triage history found for ${ticketKey}.\n`);
|
|
157
|
-
break;
|
|
158
|
-
}
|
|
159
|
-
const hs = createStyler({ isTTY: process.stdout.isTTY });
|
|
160
|
-
process.stdout.write(`\nHistory for ${hs.bold(ticketKey)} (${entries.length} entries)\n\n`);
|
|
161
|
-
for (const e of entries) {
|
|
162
|
-
const bounce = e.bounced ? hs.yellow(' ⟳ bounced') : '';
|
|
163
|
-
const urg = e.urgency === 'needs-response' ? hs.red(e.urgency) : e.urgency === 'aging' ? hs.yellow(e.urgency) : hs.green(e.urgency);
|
|
164
|
-
process.stdout.write(` ${hs.dim(e.date)} [${e.profile}] ${urg}${bounce} ${hs.dim(e.reason)}\n`);
|
|
165
|
-
}
|
|
166
|
-
process.stdout.write('\n');
|
|
149
|
+
});
|
|
167
150
|
break;
|
|
168
151
|
}
|
|
169
152
|
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.38.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.
|
|
@@ -125,6 +125,46 @@ Requires a Team license and at least one teammate in the same group. Compares yo
|
|
|
125
125
|
|
|
126
126
|
Display the script's stdout directly. No plan mode. Stop here.
|
|
127
127
|
|
|
128
|
+
If this harness has TicketLens's MCP server configured (a tool named `collisions` — often shown as `mcp__ticketlens__collisions` — visible in your tool list), prefer it over the bash form: same Team-license gate, same `ticketlens login` requirement, no shell command to construct. It accepts `json`/`plain`.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
### Stats subcommand
|
|
133
|
+
|
|
134
|
+
If the first argument is `stats`:
|
|
135
|
+
|
|
136
|
+
Run:
|
|
137
|
+
```bash
|
|
138
|
+
ticketlens stats $EXTRA_ARGS
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Where `$EXTRA_ARGS` are any flags passed (e.g. `--days=14 --format=json --profile=acme`).
|
|
142
|
+
|
|
143
|
+
Shows response-time and triage-cadence metrics from local triage history: avg/median response time, clear rate, triage run count, current urgency breakdown. Read-only, entirely local — no network call. `--days=N` (default 7; Free silently caps at 7, Pro allows up to 30); `--format=plain` (default, human-readable table) or `--format=json` (scripting).
|
|
144
|
+
|
|
145
|
+
Display the script's stdout directly. No plan mode. Stop here.
|
|
146
|
+
|
|
147
|
+
If this harness has TicketLens's MCP server configured (a tool named `stats` — often shown as `mcp__ticketlens__stats` — visible in your tool list), prefer it over the bash form: same Free/Pro day-cap split, no shell command to construct. It accepts `profile`/`days`/`format`.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
### History subcommand
|
|
152
|
+
|
|
153
|
+
If the first argument is `history`:
|
|
154
|
+
|
|
155
|
+
Run:
|
|
156
|
+
```bash
|
|
157
|
+
ticketlens history TICKET-KEY
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Requires a ticket key as the first positional argument. Requires a Pro license.
|
|
161
|
+
|
|
162
|
+
Shows the ticket's urgency timeline from local triage history — every prior triage scan that surfaced it, with the urgency level and reason computed at that point in time. Read-only, entirely local — no network call, no other flags.
|
|
163
|
+
|
|
164
|
+
Display the script's stdout directly. No plan mode. Stop here.
|
|
165
|
+
|
|
166
|
+
If this harness has TicketLens's MCP server configured (a tool named `history` — often shown as `mcp__ticketlens__history` — visible in your tool list), prefer it over the bash form: same Pro gate, no shell command to construct. It accepts `ticket`.
|
|
167
|
+
|
|
128
168
|
---
|
|
129
169
|
|
|
130
170
|
### Standup subcommand
|
|
@@ -18,6 +18,19 @@ export const NOTE_ADD_RE = /\bticketlens\s+note\s+add\b|\/jtb\s+note\b/;
|
|
|
18
18
|
// (mcp__<server-alias>__<tool-name>) — the server alias is whatever the user
|
|
19
19
|
// named it in their own .mcp.json, so only the tool-name suffix is fixed.
|
|
20
20
|
export const NOTE_ADD_MCP_RE = /^mcp__.+__recall_add$/;
|
|
21
|
+
// Matches jtb's fetch: the CLI's bare/default ticket-key form ("ticketlens
|
|
22
|
+
// PROJ-123" or its "tl" bin alias), the "get" alias form, and the /jtb skill
|
|
23
|
+
// wrapper. Deliberately excludes an explicit "fetch" subcommand — there is
|
|
24
|
+
// no such subcommand; parseCommand() (cli.mjs) only special-cases "get" as
|
|
25
|
+
// an alias, so "ticketlens fetch PROJ-123" falls through to the catch-all
|
|
26
|
+
// with "fetch" itself still in argv and errors as an invalid ticket key
|
|
27
|
+
// (live-verified during code review) — matching it here would silently
|
|
28
|
+
// swallow a broken invocation as if a real fetch had happened. Also
|
|
29
|
+
// deliberately does not match any other tracked subcommand (triage/
|
|
30
|
+
// compliance/etc) — those are lowercase words and can never satisfy the
|
|
31
|
+
// uppercase ticket-key class required immediately after the command name.
|
|
32
|
+
export const FETCH_RE = /\bticketlens\s+(?:get\s+)?[A-Z][A-Z0-9]{1,9}-\d+\b|\btl\s+(?:get\s+)?[A-Z][A-Z0-9]{1,9}-\d+\b|\/jtb\s+(?:get\s+)?[A-Z][A-Z0-9]{1,9}-\d+\b/;
|
|
33
|
+
export const FETCH_MCP_RE = /^mcp__.+__fetch$/;
|
|
21
34
|
|
|
22
35
|
export function readStdinJson() {
|
|
23
36
|
const raw = fs.readFileSync(0, 'utf8');
|
|
@@ -99,6 +112,50 @@ export function hasRecentCapture(cwd, now = Date.now()) {
|
|
|
99
112
|
return lastCaptureAt > 0 && (now - lastCaptureAt) < CAPTURE_FRESHNESS_MS;
|
|
100
113
|
}
|
|
101
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Cross-session NAG marker (backlog #14) — same shape as lastCapturePath/
|
|
117
|
+
* readLastCaptureAt/writeLastCaptureAt/hasRecentCapture above, but records
|
|
118
|
+
* "we already blocked once for this directory" instead of "a note was
|
|
119
|
+
* added". Closes a gap those functions never covered: they bridge a
|
|
120
|
+
* session_id rollover only when a REAL capture landed, but a dismissed nag
|
|
121
|
+
* ("genuinely nothing qualified" — the hook's own suggested response) was
|
|
122
|
+
* never remembered anywhere. Since compaction/resume mints a brand-new
|
|
123
|
+
* session_id (see recall-nudge-stop.mjs's docstring), and that resets the
|
|
124
|
+
* per-session_id stopChecked gate, one long working session with several
|
|
125
|
+
* compaction cycles could get nagged repeatedly for the same still-ongoing
|
|
126
|
+
* work — a real, reported friction source (backlog #14), not hypothetical.
|
|
127
|
+
* Deliberately a separate marker file from lastCapture (not folded into
|
|
128
|
+
* it): a nag and a capture are different facts, and conflating them would
|
|
129
|
+
* make hasRecentCapture's "a real capture landed" guarantee ambiguous.
|
|
130
|
+
* Reuses CAPTURE_FRESHNESS_MS rather than a second magic number — the same
|
|
131
|
+
* 2-hour window is a reasonable proxy for "still the same working session"
|
|
132
|
+
* in both cases, and a distinct constant isn't justified by anything
|
|
133
|
+
* observed so far.
|
|
134
|
+
*/
|
|
135
|
+
export function lastNagPath(cwd) {
|
|
136
|
+
const hash = crypto.createHash('sha256').update(cwd || 'unknown').digest('hex').slice(0, 16);
|
|
137
|
+
return path.join(privateTmpDir(), `lastnag-${hash}.json`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function readLastNagAt(cwd) {
|
|
141
|
+
try {
|
|
142
|
+
return JSON.parse(fs.readFileSync(lastNagPath(cwd), 'utf8')).lastNagAt ?? 0;
|
|
143
|
+
} catch {
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function writeLastNagAt(cwd, timestamp) {
|
|
149
|
+
try {
|
|
150
|
+
fs.writeFileSync(lastNagPath(cwd), JSON.stringify({ lastNagAt: timestamp }));
|
|
151
|
+
} catch { /* best-effort — losing this marker only costs one extra nag next rollover */ }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function hasRecentNag(cwd, now = Date.now()) {
|
|
155
|
+
const lastNagAt = readLastNagAt(cwd);
|
|
156
|
+
return lastNagAt > 0 && (now - lastNagAt) < CAPTURE_FRESHNESS_MS;
|
|
157
|
+
}
|
|
158
|
+
|
|
102
159
|
/**
|
|
103
160
|
* Reads the transcript (JSONL) and returns simple booleans about what
|
|
104
161
|
* happened this session. Best-effort: any read/parse failure returns all
|
|
@@ -126,9 +183,19 @@ export function hasRecentCapture(cwd, now = Date.now()) {
|
|
|
126
183
|
* early, and picking one deterministic match keeps this function decoupled
|
|
127
184
|
* from profiles.json (it stays a pure transcript reader; profile lookup
|
|
128
185
|
* and its own fallback chain belong entirely to resolveProfile()).
|
|
186
|
+
*
|
|
187
|
+
* `sawFetch` (backlog #15) is deliberately narrower than `sawTicketKey`: it
|
|
188
|
+
* only goes true when jtb's fetch tool actually ran (CLI or MCP form, same
|
|
189
|
+
* dual-detection shape as sawNoteAdd below) — not merely when a ticket-key-
|
|
190
|
+
* shaped string appears anywhere in the transcript. `sawTicketKey` false-
|
|
191
|
+
* positived on any incidental match (a doc, a code comment, a test fixture
|
|
192
|
+
* name like BETA-42), which is fine for its own low-stakes use (picking
|
|
193
|
+
* which profile's settings apply) but was wrong as shouldNag()'s nag
|
|
194
|
+
* precondition — SKILL.md's own capture guidance is scoped to "whenever
|
|
195
|
+
* jtb's fetch was used," so the hook now checks the same thing it backstops.
|
|
129
196
|
*/
|
|
130
197
|
export function scanTranscript(transcriptPath) {
|
|
131
|
-
const result = { sawTicketKey: false, sawRecallFlag: false, sawNoteAdd: false, ticketKey: null };
|
|
198
|
+
const result = { sawTicketKey: false, sawRecallFlag: false, sawNoteAdd: false, sawFetch: false, ticketKey: null };
|
|
132
199
|
let lines;
|
|
133
200
|
try {
|
|
134
201
|
lines = fs.readFileSync(transcriptPath, 'utf8').split('\n').filter(Boolean);
|
|
@@ -173,6 +240,10 @@ export function scanTranscript(transcriptPath) {
|
|
|
173
240
|
const isCliNoteAdd = block.name === 'Bash' && NOTE_ADD_RE.test(block.input?.command ?? '');
|
|
174
241
|
const isMcpNoteAdd = NOTE_ADD_MCP_RE.test(block.name ?? '');
|
|
175
242
|
if (isCliNoteAdd || isMcpNoteAdd) result.sawNoteAdd = true;
|
|
243
|
+
|
|
244
|
+
const isCliFetch = block.name === 'Bash' && FETCH_RE.test(block.input?.command ?? '');
|
|
245
|
+
const isMcpFetch = FETCH_MCP_RE.test(block.name ?? '');
|
|
246
|
+
if (isCliFetch || isMcpFetch) result.sawFetch = true;
|
|
176
247
|
}
|
|
177
248
|
}
|
|
178
249
|
}
|
|
@@ -191,9 +262,17 @@ export function scanTranscript(transcriptPath) {
|
|
|
191
262
|
* compaction/session_id rollover, or nagging more than once per session).
|
|
192
263
|
* Strict's actual effect on capture volume comes from SKILL.md's lowered
|
|
193
264
|
* in-session capture bar, not from this function.
|
|
265
|
+
*
|
|
266
|
+
* Gated on `sawFetch`, not `sawTicketKey` (backlog #15) — including the
|
|
267
|
+
* `sawRecallFlag` broken-promise case, which is why the gate is a blanket
|
|
268
|
+
* `!sawFetch` check rather than per-branch: a flag can't legitimately fire
|
|
269
|
+
* outside real ticket work, and this keeps the whole function's trigger
|
|
270
|
+
* matching SKILL.md's own capture-guidance scope exactly ("unconditionally
|
|
271
|
+
* whenever jtb's fetch was used"), instead of firing on any incidental
|
|
272
|
+
* ticket-key-shaped string.
|
|
194
273
|
*/
|
|
195
|
-
export function shouldNag({
|
|
196
|
-
if (!
|
|
274
|
+
export function shouldNag({ sawFetch, sawRecallFlag, sawNoteAdd, recallStrictness = 'balanced' }) {
|
|
275
|
+
if (!sawFetch || sawNoteAdd) return false;
|
|
197
276
|
if (recallStrictness === 'loose') return sawRecallFlag; // only the broken-promise case
|
|
198
|
-
return true; // balanced and strict:
|
|
277
|
+
return true; // balanced and strict: a fetch with no note is enough
|
|
199
278
|
}
|
|
@@ -3,19 +3,28 @@
|
|
|
3
3
|
* Stop hook — end-of-session Recall check.
|
|
4
4
|
*
|
|
5
5
|
* Blocks (exit 2) at most ONCE per session — never traps the user in a
|
|
6
|
-
* loop regardless of how Claude responds.
|
|
6
|
+
* loop regardless of how Claude responds. Both cases below require jtb's
|
|
7
|
+
* fetch to have actually run this session (backlog #15) — a ticket-key-
|
|
8
|
+
* shaped string appearing incidentally (a doc, a code comment, a test
|
|
9
|
+
* fixture) is not enough, matching SKILL.md's own capture-guidance scope.
|
|
10
|
+
* Given that:
|
|
7
11
|
* 1. Claude flagged something (🔖 Recall-flag:) but never called note add
|
|
8
12
|
* — a broken promise, the strongest signal something was missed.
|
|
9
13
|
* 2. Ticket work happened all session with zero flags and zero notes
|
|
10
14
|
* — the weaker "did anything ever get considered?" catch.
|
|
11
|
-
* Anything else (no
|
|
15
|
+
* Anything else (no fetch this session, or a note was already added) exits
|
|
12
16
|
* clean — this must never be the reason a session can't end.
|
|
13
17
|
*
|
|
14
18
|
* The per-session_id "asked once" state (readState/writeState) cannot
|
|
15
19
|
* survive a compaction/resume event — that hands this hook a brand-new
|
|
16
20
|
* session_id, a blank dedup state, AND a blank transcript file, so a real
|
|
17
21
|
* earlier capture becomes invisible. The cross-session lastCapture marker
|
|
18
|
-
* (keyed by cwd, not session_id) is what actually bridges that boundary
|
|
22
|
+
* (keyed by cwd, not session_id) is what actually bridges that boundary
|
|
23
|
+
* for a genuine capture. The parallel lastNag marker (backlog #14) bridges
|
|
24
|
+
* the same boundary for a DISMISSED nag: without it, a session that already
|
|
25
|
+
* got its one nag and was told "genuinely nothing qualified" would nag
|
|
26
|
+
* again after the next compaction/resume rollover, since that dismissal
|
|
27
|
+
* was never recorded anywhere — only a real capture was.
|
|
19
28
|
*
|
|
20
29
|
* Which of the two cases above actually blocks is governed by the active
|
|
21
30
|
* profile's recallStrictness — see recall-nudge-lib.mjs's shouldNag() doc
|
|
@@ -30,7 +39,7 @@
|
|
|
30
39
|
* profile, same as before (backlog #12, design spec §6).
|
|
31
40
|
*/
|
|
32
41
|
|
|
33
|
-
import { readStdinJson, readState, writeState, scanTranscript, hasRecentCapture, writeLastCaptureAt, shouldNag } from './recall-nudge-lib.mjs';
|
|
42
|
+
import { readStdinJson, readState, writeState, scanTranscript, hasRecentCapture, writeLastCaptureAt, hasRecentNag, writeLastNagAt, shouldNag } from './recall-nudge-lib.mjs';
|
|
34
43
|
import { resolveProfile, normalizeRecallStrictness } from '../scripts/lib/profile-resolver.mjs';
|
|
35
44
|
|
|
36
45
|
const input = readStdinJson();
|
|
@@ -40,7 +49,7 @@ const cwd = input?.cwd ?? process.cwd();
|
|
|
40
49
|
|
|
41
50
|
if (!sessionId || !transcriptPath) process.exit(0);
|
|
42
51
|
|
|
43
|
-
const {
|
|
52
|
+
const { sawFetch, sawRecallFlag, sawNoteAdd, ticketKey } = scanTranscript(transcriptPath);
|
|
44
53
|
|
|
45
54
|
// Refreshed on every check, independent of the once-per-session gate below —
|
|
46
55
|
// a capture that happens AFTER this session already nagged once must still
|
|
@@ -53,7 +62,7 @@ if (state.stopChecked) process.exit(0); // already asked once this session — r
|
|
|
53
62
|
const profile = resolveProfile(ticketKey, { cwd });
|
|
54
63
|
const recallStrictness = normalizeRecallStrictness(profile?.recallStrictness);
|
|
55
64
|
|
|
56
|
-
if (!shouldNag({
|
|
65
|
+
if (!shouldNag({ sawFetch, sawRecallFlag, sawNoteAdd, recallStrictness })) {
|
|
57
66
|
process.exit(0); // nothing this strictness level requires a capture for
|
|
58
67
|
}
|
|
59
68
|
|
|
@@ -61,8 +70,13 @@ if (hasRecentCapture(cwd)) {
|
|
|
61
70
|
process.exit(0); // a real capture landed recently in this same directory, just under a different session_id
|
|
62
71
|
}
|
|
63
72
|
|
|
73
|
+
if (hasRecentNag(cwd)) {
|
|
74
|
+
process.exit(0); // already nagged recently in this same directory, just under a different session_id — a compaction/resume rollover, not a fresh session (backlog #14)
|
|
75
|
+
}
|
|
76
|
+
|
|
64
77
|
state.stopChecked = true;
|
|
65
78
|
writeState(sessionId, state);
|
|
79
|
+
writeLastNagAt(cwd, Date.now());
|
|
66
80
|
|
|
67
81
|
if (sawRecallFlag) {
|
|
68
82
|
process.stderr.write(
|
|
@@ -511,7 +511,9 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
511
511
|
const errStream = { write: printErrFn, isTTY };
|
|
512
512
|
|
|
513
513
|
if (args.includes('--help') || args.includes('-h')) {
|
|
514
|
-
|
|
514
|
+
// stdout's own isTTY, not the `isTTY` above (that one is documented as
|
|
515
|
+
// reflecting process.stderr.isTTY for the stderr-bound helpers below).
|
|
516
|
+
printFetchHelp({ stream: { write: printFn, isTTY: process.stdout.isTTY } });
|
|
515
517
|
return;
|
|
516
518
|
}
|
|
517
519
|
|
|
@@ -645,16 +645,20 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
|
645
645
|
` ${s.bold(s.brand('ticketlens'))} ${s.bold('mcp')}`,
|
|
646
646
|
'',
|
|
647
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('review')}, ${s.cyan('standup')}, ${s.cyan('pr')}, ${s.cyan('
|
|
649
|
-
` ${s.cyan('
|
|
650
|
-
` ${s.cyan('
|
|
651
|
-
`
|
|
652
|
-
` ${s.cyan('
|
|
648
|
+
` native tools — ${s.cyan('fetch')}, ${s.cyan('triage')}, ${s.cyan('compliance')}, ${s.cyan('review')}, ${s.cyan('standup')}, ${s.cyan('pr')}, ${s.cyan('stats')}, ${s.cyan('history')},`,
|
|
649
|
+
` ${s.cyan('collisions')}, ${s.cyan('doctor')}, ${s.cyan('recall_add')}, ${s.cyan('recall_search')}, ${s.cyan('ticket_comment')}, ${s.cyan('ticket_transition')},`,
|
|
650
|
+
` ${s.cyan('ticket_assign')}, ${s.cyan('ticket_duplicates')}, ${s.cyan('ticket_link')}, ${s.cyan('ticket_update')}, ${s.cyan('ticket_create')} — for any`,
|
|
651
|
+
` MCP-compatible AI harness, not just Claude Code. Thin adapter over the same`,
|
|
652
|
+
` code as ${s.cyan('TICKET-KEY')}/${s.cyan('doctor')}/${s.cyan('triage')}/${s.cyan('compliance')}/${s.cyan('review')}/${s.cyan('standup')}/${s.cyan('pr')}/${s.cyan('stats')}/${s.cyan('history')}/${s.cyan('collisions')}/`,
|
|
653
|
+
` ${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')} above.`,
|
|
653
654
|
` ${s.cyan('fetch')}, ${s.cyan('doctor')}, and ${s.cyan('standup')} are Free; ${s.cyan('triage')} is Free with some Pro/Team-gated`,
|
|
654
655
|
` options (see ${s.cyan('ticketlens triage --help')}); ${s.cyan('compliance')} and ${s.cyan('pr')} are Free, sharing a`,
|
|
655
656
|
` 3-checks/month cap on their requirements-coverage section, Pro unlimited;`,
|
|
656
657
|
` ${s.cyan('review')} is Free for branch/files/ticket context — its coverage/focus section`,
|
|
657
|
-
` requires Pro as a plain license check, not a draw on that same counter
|
|
658
|
+
` requires Pro as a plain license check, not a draw on that same counter;`,
|
|
659
|
+
` ${s.cyan('stats')} is Free with a 7-day lookback, Pro extends it to 30 days, same split`,
|
|
660
|
+
` as ${s.cyan('ticketlens stats --help')}; ${s.cyan('history')} is entirely local and requires Pro;`,
|
|
661
|
+
` ${s.cyan('collisions')} requires ${s.cyan('ticketlens login')} (Console access) plus a Team license — every`,
|
|
658
662
|
` other tool needs Pro. ${s.cyan('ticket_transition')} is destructive when called with`,
|
|
659
663
|
` \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')} is currently self-assign only;`,
|
|
660
664
|
` ${s.cyan('ticket_duplicates')} is read-only; ${s.cyan('ticket_link')} on GitHub closes the source issue as a`,
|
|
@@ -27,226 +27,13 @@ import { runRecall } from './recall-command.mjs';
|
|
|
27
27
|
import { runTicketComment, runTicketTransitionList, runTicketTransition, runTicketAssign, runTicketDuplicates, runTicketLinkList, runTicketLink, runTicketUpdate, runTicketCreate } from './ticket-command.mjs';
|
|
28
28
|
import { run as runFetchTicket } from '../fetch-ticket.mjs';
|
|
29
29
|
import { run as runTriage } from '../fetch-my-tickets.mjs';
|
|
30
|
+
import { runStats } from './run-stats.mjs';
|
|
31
|
+
import { runHistory } from './run-history.mjs';
|
|
32
|
+
import { runCollisions } from './run-collisions.mjs';
|
|
33
|
+
import { TOOLS } from './mcp-tool-schemas.mjs';
|
|
30
34
|
|
|
31
35
|
const PROTOCOL_VERSION = '2025-11-25';
|
|
32
36
|
|
|
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
|
-
},
|
|
81
|
-
{
|
|
82
|
-
name: 'review',
|
|
83
|
-
description: 'Assemble PR review context from the current git branch — changed files, linked-ticket summaries, and (Pro) a requirements-coverage / review-focus section extracted from the diff against acceptance criteria. Read-only; never modifies the tracker or the repo. Free tier gets branch info, changed files, and ticket context; TicketLens Pro adds the requirements-coverage and review-focus sections — same split as the `compliance` tool.',
|
|
84
|
-
inputSchema: {
|
|
85
|
-
type: 'object',
|
|
86
|
-
properties: {
|
|
87
|
-
base: { type: 'string', description: 'Base branch to diff against. Auto-detects main/master/develop when omitted. Alias of `branch` — if both are given, `base` wins.' },
|
|
88
|
-
branch: { type: 'string', description: 'Alias for `base` — same effect, provided for parity with the CLI\'s `--branch=` flag.' },
|
|
89
|
-
profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
|
|
90
|
-
},
|
|
91
|
-
},
|
|
92
|
-
},
|
|
93
|
-
{
|
|
94
|
-
name: 'standup',
|
|
95
|
-
description: 'Summarize recent git commits grouped by linked ticket — a standup update or, with format:"pr", PR-body-style formatting. Read-only, fully free tier — no license gate on any option.',
|
|
96
|
-
inputSchema: {
|
|
97
|
-
type: 'object',
|
|
98
|
-
properties: {
|
|
99
|
-
since: { type: 'string', description: 'How far back to scan — an integer number of hours ("24") or a git-compatible date expression ("3 days ago"). Defaults to 24 hours.' },
|
|
100
|
-
format: { type: 'string', enum: ['standup', 'pr'], description: 'Output shape: "standup" (default) groups commits under a per-ticket standup update; "pr" renders the same grouped commits as PR-body-style markdown.' },
|
|
101
|
-
profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
|
|
102
|
-
},
|
|
103
|
-
},
|
|
104
|
-
},
|
|
105
|
-
{
|
|
106
|
-
name: 'pr',
|
|
107
|
-
description: 'Assemble a ready-to-paste PR description for a ticket — what changed (from linked commits), linked tickets, and (if the ticket has acceptance criteria) a requirements-coverage section. Read-only. The requirements-coverage section reuses the same Free-tier 3-checks/month counter as the `compliance` tool — calling `pr` on a ticket with acceptance criteria counts against that shared monthly limit; TicketLens Pro removes the cap.',
|
|
108
|
-
inputSchema: {
|
|
109
|
-
type: 'object',
|
|
110
|
-
properties: {
|
|
111
|
-
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
112
|
-
profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
|
|
113
|
-
},
|
|
114
|
-
required: ['ticket'],
|
|
115
|
-
},
|
|
116
|
-
},
|
|
117
|
-
{
|
|
118
|
-
name: 'doctor',
|
|
119
|
-
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.',
|
|
120
|
-
inputSchema: {
|
|
121
|
-
type: 'object',
|
|
122
|
-
properties: {
|
|
123
|
-
fix: { type: 'boolean', description: 'Attempt safe, non-destructive repairs for failing checks.' },
|
|
124
|
-
profile: { type: 'string', description: 'Scope profile/connectivity/cache checks to one profile.' },
|
|
125
|
-
},
|
|
126
|
-
},
|
|
127
|
-
},
|
|
128
|
-
{
|
|
129
|
-
name: 'recall_add',
|
|
130
|
-
description: 'Save a Recall note — a gotcha, root cause, or non-obvious decision learned this session. Requires a TicketLens Pro license.',
|
|
131
|
-
inputSchema: {
|
|
132
|
-
type: 'object',
|
|
133
|
-
properties: {
|
|
134
|
-
title: { type: 'string', description: 'Short one-line title.' },
|
|
135
|
-
ticket: { type: 'string', description: 'Optional ticket key, e.g. PROJ-123.' },
|
|
136
|
-
tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags derived from this note\'s actual content — the specific technology, error type, root cause, or affected component (e.g. "retry-backoff", "null-pointer", "auth-middleware"). Never the project name or a generic category word like "gotcha" or "bug" — those provide no search signal to someone else looking for this note later. A tag that just restates the title in different words, or one you cannot trace to a specific sentence in the body, gives that same zero signal — if you cannot point to the exact phrase that justifies it, drop it.' },
|
|
137
|
-
body: { type: 'string', description: 'The note body — one or more paragraphs.' },
|
|
138
|
-
},
|
|
139
|
-
required: ['title', 'body'],
|
|
140
|
-
},
|
|
141
|
-
},
|
|
142
|
-
{
|
|
143
|
-
name: 'recall_search',
|
|
144
|
-
description: 'Search saved Recall notes by free-text query or ticket key. Requires a TicketLens Pro license.',
|
|
145
|
-
inputSchema: {
|
|
146
|
-
type: 'object',
|
|
147
|
-
properties: {
|
|
148
|
-
query: { type: 'string', description: 'Free-text query or a ticket key like PROJ-123.' },
|
|
149
|
-
},
|
|
150
|
-
required: ['query'],
|
|
151
|
-
},
|
|
152
|
-
},
|
|
153
|
-
{
|
|
154
|
-
name: 'ticket_comment',
|
|
155
|
-
description: 'Post a comment to a ticket in its tracker (Jira/GitHub/Linear). Destructive — writes directly to the live tracker, not a local Recall note. Requires a TicketLens Pro license.',
|
|
156
|
-
inputSchema: {
|
|
157
|
-
type: 'object',
|
|
158
|
-
properties: {
|
|
159
|
-
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
160
|
-
body: { type: 'string', description: 'Comment body.' },
|
|
161
|
-
attachments: { type: 'array', items: { type: 'string' }, description: 'Local file paths to attach — images render as a real inline thumbnail in the posted comment on both Jira (Cloud and Server/Data Center) and Linear. Not supported on GitHub — no PAT-compatible upload API exists there.' },
|
|
162
|
-
},
|
|
163
|
-
required: ['ticket', 'body'],
|
|
164
|
-
},
|
|
165
|
-
},
|
|
166
|
-
{
|
|
167
|
-
name: 'ticket_transition',
|
|
168
|
-
description: 'List or execute a ticket status transition in its tracker (Jira/GitHub/Linear). Called with only `ticket`, lists the tracker\'s current valid options without changing anything. Destructive when `target` and `confirm: true` are both given — requires confirmation and writes directly to the live tracker. Requires a TicketLens Pro license.',
|
|
169
|
-
inputSchema: {
|
|
170
|
-
type: 'object',
|
|
171
|
-
properties: {
|
|
172
|
-
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
173
|
-
target: { type: 'string', description: 'Target status/transition name. Omit to just list the tracker\'s current valid options.' },
|
|
174
|
-
confirm: { type: 'boolean', description: 'Must be true, alongside `target`, to actually execute the transition — a nudge and audit trail, not just a formality.' },
|
|
175
|
-
},
|
|
176
|
-
required: ['ticket'],
|
|
177
|
-
},
|
|
178
|
-
},
|
|
179
|
-
{
|
|
180
|
-
name: 'ticket_assign',
|
|
181
|
-
description: 'Assign a ticket to yourself in its tracker (Jira/GitHub/Linear). Self-assign only — assigning to someone else is not supported yet. Requires a TicketLens Pro license.',
|
|
182
|
-
inputSchema: {
|
|
183
|
-
type: 'object',
|
|
184
|
-
properties: {
|
|
185
|
-
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
186
|
-
to: { type: 'string', description: 'Who to assign to — currently only "me" is accepted.' },
|
|
187
|
-
},
|
|
188
|
-
required: ['ticket', 'to'],
|
|
189
|
-
},
|
|
190
|
-
},
|
|
191
|
-
{
|
|
192
|
-
name: 'ticket_duplicates',
|
|
193
|
-
description: 'Find likely duplicate tickets in the same project (Jira/GitHub/Linear). Read-only — never links or changes anything. On Jira, any ticket already linked as a "Duplicate" is always included first (a confirmed relationship, not a guess); everything else comes from a local, approximate title/description overlap score, since no tracker scores similarity server-side. That scorer can miss real duplicates as easily as it over-matches, so an empty result means none were found, not a guarantee that none exist. Requires a TicketLens Pro license.',
|
|
194
|
-
inputSchema: {
|
|
195
|
-
type: 'object',
|
|
196
|
-
properties: {
|
|
197
|
-
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
198
|
-
threshold: { type: 'number', description: 'Minimum match score 0-1 to report. Defaults to 0.35.' },
|
|
199
|
-
},
|
|
200
|
-
required: ['ticket'],
|
|
201
|
-
},
|
|
202
|
-
},
|
|
203
|
-
{
|
|
204
|
-
name: 'ticket_link',
|
|
205
|
-
description: 'List or execute a link between two tickets in their tracker (Jira/GitHub/Linear). Called with only `ticket`/`target`, lists the tracker\'s current valid link types without changing anything. Destructive when `type` and `confirm: true` are both given — writes directly to the live tracker. Direction matters: `ticket` "types" `target` (e.g. ticket duplicates target). On GitHub, executing CLOSES `ticket` as a duplicate of `target` — a state change, not just a relationship add like Jira/Linear. Requires a TicketLens Pro license.',
|
|
206
|
-
inputSchema: {
|
|
207
|
-
type: 'object',
|
|
208
|
-
properties: {
|
|
209
|
-
ticket: { type: 'string', description: 'Source ticket key, e.g. PROJ-123 — the one that "types" target.' },
|
|
210
|
-
target: { type: 'string', description: 'Target ticket key, e.g. PROJ-456.' },
|
|
211
|
-
type: { type: 'string', description: 'Link type name (from the list). Omit to just list the tracker\'s current valid options. GitHub only supports "duplicate".' },
|
|
212
|
-
confirm: { type: 'boolean', description: 'Must be true, alongside `type`, to actually execute the link — a nudge and audit trail, not just a formality.' },
|
|
213
|
-
},
|
|
214
|
-
required: ['ticket', 'target'],
|
|
215
|
-
},
|
|
216
|
-
},
|
|
217
|
-
{
|
|
218
|
-
name: 'ticket_update',
|
|
219
|
-
description: 'Update a narrow, named field set on a ticket in its tracker (Jira/GitHub/Linear) — title, description, labels, priority. At least one field is required. Labels are add/remove, never a wholesale replace: an unnamed existing label is left alone. No discovery step and no confirm required — these are reversible metadata edits, not workflow-state changes. GitHub has no priority field; passing `priority` for a GitHub-tracked ticket is refused. A call can partially succeed (e.g. title updates but a label does not resolve) — the result reports exactly what landed. Requires a TicketLens Pro license.',
|
|
220
|
-
inputSchema: {
|
|
221
|
-
type: 'object',
|
|
222
|
-
properties: {
|
|
223
|
-
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
224
|
-
title: { type: 'string', description: 'New title/summary. Omit to leave unchanged.' },
|
|
225
|
-
description: { type: 'string', description: 'New description. Omit to leave unchanged.' },
|
|
226
|
-
addLabels: { type: 'array', items: { type: 'string' }, description: 'Labels to add. Existing labels not named here are left alone.' },
|
|
227
|
-
removeLabels: { type: 'array', items: { type: 'string' }, description: 'Labels to remove.' },
|
|
228
|
-
priority: { type: 'string', description: 'New priority name, e.g. "High". Not supported on GitHub.' },
|
|
229
|
-
},
|
|
230
|
-
required: ['ticket'],
|
|
231
|
-
},
|
|
232
|
-
},
|
|
233
|
-
{
|
|
234
|
-
name: 'ticket_create',
|
|
235
|
-
description: 'Create a new ticket in a tracker (Jira/GitHub/Linear) with a fixed minimal field set — no arbitrary custom fields. Architecturally unlike every other ticket-write tool: there is no existing ticket to target, so the target tracker/project is picked by the connection profile rather than a ticket key. `project` is the Jira project key or Linear team key — required for both, ignored on GitHub (its repo is fixed by the profile). `type` is the Jira issue type — required for Jira only, ignored elsewhere. Highest blast radius of the ticket-write family: a bad project/type fabricates a real, hard-to-walk-back item in a live tracker. Requires a TicketLens Pro license.',
|
|
236
|
-
inputSchema: {
|
|
237
|
-
type: 'object',
|
|
238
|
-
properties: {
|
|
239
|
-
project: { type: 'string', description: 'Jira project key or Linear team key. Required for Jira/Linear; ignored on GitHub.' },
|
|
240
|
-
type: { type: 'string', description: 'Jira issue type, e.g. "Task" or "Bug". Required for Jira only; ignored on GitHub/Linear.' },
|
|
241
|
-
summary: { type: 'string', description: 'Ticket title/summary.' },
|
|
242
|
-
description: { type: 'string', description: 'Ticket description. Omit for none.' },
|
|
243
|
-
attachments: { type: 'array', items: { type: 'string' }, description: 'Local file paths to attach, uploaded after the ticket is created. On Linear the image is automatically linked into the description. On Jira it becomes a real, visible attachment on the issue, but is not embedded inline in the initial description (use ticket_comment afterward for an inline thumbnail). Not supported on GitHub.' },
|
|
244
|
-
profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile. Use this when `project` belongs to a profile other than the one auto-resolved from the current working directory.' },
|
|
245
|
-
},
|
|
246
|
-
required: ['summary'],
|
|
247
|
-
},
|
|
248
|
-
},
|
|
249
|
-
];
|
|
250
37
|
|
|
251
38
|
function jsonRpcResult(id, result) {
|
|
252
39
|
return JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n';
|
|
@@ -471,6 +258,70 @@ async function callDoctor(args, { configDir, runDoctorFn }) {
|
|
|
471
258
|
return { content: [{ type: 'text', text: capture.text }] };
|
|
472
259
|
}
|
|
473
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Shared by `stats` and `history` — both wrap a run*Fn taking {configDir,
|
|
263
|
+
* print, warn} where a real report always reaches `print` on success and
|
|
264
|
+
* every failure (validation error, license gate) reaches `warn`. Same
|
|
265
|
+
* "did print receive text" success heuristic as callTriage, factored out
|
|
266
|
+
* here the way callFetchTicketRun already factors it out for the
|
|
267
|
+
* fetch/compliance/review/standup/pr family. NOT used by `collisions` —
|
|
268
|
+
* see callCollisions for why that one needs the returned {ok} instead.
|
|
269
|
+
*/
|
|
270
|
+
async function callPrintWarnRun(buildArgsFn, args, { configDir, runFn }, fallbackErrorText) {
|
|
271
|
+
const capture = capturingStream();
|
|
272
|
+
const errCapture = capturingStream();
|
|
273
|
+
await runFn(buildArgsFn(args), { configDir, print: capture.write, warn: errCapture.write });
|
|
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
|
+
function buildStatsArgs({ profile, days, format }) {
|
|
281
|
+
const args = [];
|
|
282
|
+
if (profile) args.push(`--profile=${profile}`);
|
|
283
|
+
if (days !== undefined) args.push(`--days=${days}`);
|
|
284
|
+
if (format) args.push(`--format=${format}`);
|
|
285
|
+
return args;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function callStats(args, { configDir, runStatsFn }) {
|
|
289
|
+
return callPrintWarnRun(buildStatsArgs, args, { configDir, runFn: runStatsFn }, 'stats failed');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function buildHistoryArgs({ ticket }) {
|
|
293
|
+
return [ticket];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function callHistory(args, { configDir, runHistoryFn }) {
|
|
297
|
+
if (!args.ticket) {
|
|
298
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
|
|
299
|
+
}
|
|
300
|
+
return callPrintWarnRun(buildHistoryArgs, args, { configDir, runFn: runHistoryFn }, 'history failed');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function buildCollisionsArgs({ json, plain }) {
|
|
304
|
+
const args = [];
|
|
305
|
+
if (json === true) args.push('--json');
|
|
306
|
+
if (plain === true) args.push('--plain');
|
|
307
|
+
return args;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Unlike every other read tool here, runCollisions writes its own failure
|
|
312
|
+
* messages (no-token, 401, 403, network error) through `print`, not `warn` —
|
|
313
|
+
* so the "did print receive text" heuristic used by callStats/callTriage
|
|
314
|
+
* would misreport every one of those as a success. Uses the returned {ok}
|
|
315
|
+
* instead, same as callTicketCreate.
|
|
316
|
+
*/
|
|
317
|
+
async function callCollisions(args, { configDir, runCollisionsFn }) {
|
|
318
|
+
const capture = capturingStream();
|
|
319
|
+
const errCapture = capturingStream();
|
|
320
|
+
const { ok } = await runCollisionsFn(buildCollisionsArgs(args), { configDir, print: capture.write, warn: errCapture.write });
|
|
321
|
+
const content = [{ type: 'text', text: capture.text }];
|
|
322
|
+
return ok ? { content } : { isError: true, content };
|
|
323
|
+
}
|
|
324
|
+
|
|
474
325
|
/**
|
|
475
326
|
* Builds runNoteAdd's cmdArgs array. Each `--flag=value` MUST stay a single,
|
|
476
327
|
* discrete array element — runNoteAdd's parseFlag matches per-element via
|
|
@@ -682,6 +533,9 @@ async function handleToolsCall(params, deps) {
|
|
|
682
533
|
if (name === 'standup') return callStandup(args, deps);
|
|
683
534
|
if (name === 'pr') return callPr(args, deps);
|
|
684
535
|
if (name === 'doctor') return callDoctor(args, deps);
|
|
536
|
+
if (name === 'stats') return callStats(args, deps);
|
|
537
|
+
if (name === 'history') return callHistory(args, deps);
|
|
538
|
+
if (name === 'collisions') return callCollisions(args, deps);
|
|
685
539
|
if (name === 'recall_add') return callRecallAdd(args, deps);
|
|
686
540
|
if (name === 'recall_search') return callRecallSearch(args, deps);
|
|
687
541
|
if (name === 'ticket_comment') return callTicketComment(args, deps);
|
|
@@ -694,7 +548,7 @@ async function handleToolsCall(params, deps) {
|
|
|
694
548
|
return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
|
|
695
549
|
}
|
|
696
550
|
|
|
697
|
-
async function handleMessage(raw, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn }) {
|
|
551
|
+
async function handleMessage(raw, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runStatsFn, runHistoryFn, runCollisionsFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn }) {
|
|
698
552
|
let msg;
|
|
699
553
|
try {
|
|
700
554
|
msg = JSON.parse(raw);
|
|
@@ -724,7 +578,7 @@ async function handleMessage(raw, { configDir, runFetchTicketFn, runTriageFn, ru
|
|
|
724
578
|
|
|
725
579
|
if (method === 'tools/call') {
|
|
726
580
|
try {
|
|
727
|
-
const result = await handleToolsCall(params, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
|
|
581
|
+
const result = await handleToolsCall(params, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runStatsFn, runHistoryFn, runCollisionsFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
|
|
728
582
|
return jsonRpcResult(id, result);
|
|
729
583
|
} catch (err) {
|
|
730
584
|
return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
|
|
@@ -748,6 +602,9 @@ export function runMcpServer({
|
|
|
748
602
|
runFetchTicketFn = runFetchTicket,
|
|
749
603
|
runTriageFn = runTriage,
|
|
750
604
|
runDoctorFn = runDoctor,
|
|
605
|
+
runStatsFn = runStats,
|
|
606
|
+
runHistoryFn = runHistory,
|
|
607
|
+
runCollisionsFn = runCollisions,
|
|
751
608
|
runNoteAddFn = runNoteAdd,
|
|
752
609
|
runRecallFn = runRecall,
|
|
753
610
|
runTicketCommentFn = runTicketComment,
|
|
@@ -778,7 +635,7 @@ export function runMcpServer({
|
|
|
778
635
|
// never resolving (a dropped rejection isn't a resolution) — the
|
|
779
636
|
// server would hang on shutdown instead of exiting.
|
|
780
637
|
queue = queue.then(async () => {
|
|
781
|
-
const response = await handleMessage(line, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
|
|
638
|
+
const response = await handleMessage(line, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runStatsFn, runHistoryFn, runCollisionsFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
|
|
782
639
|
if (response) stdout.write(response);
|
|
783
640
|
}).catch(() => {});
|
|
784
641
|
});
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON-RPC `tools/list` schema definitions for every MCP tool `mcp-server.mjs`
|
|
3
|
+
* exposes. Pure data — no logic, no imports — extracted out of mcp-server.mjs
|
|
4
|
+
* to keep that file under the project's 800-line cap. Order here is the order
|
|
5
|
+
* clients see in `tools/list`.
|
|
6
|
+
*/
|
|
7
|
+
export const TOOLS = [
|
|
8
|
+
{
|
|
9
|
+
name: 'fetch',
|
|
10
|
+
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.',
|
|
11
|
+
inputSchema: {
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
15
|
+
profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
|
|
16
|
+
depth: { type: 'number', description: 'How many hops of linked tickets to traverse. Defaults to 1 (direct links only). 0 disables traversal.' },
|
|
17
|
+
},
|
|
18
|
+
required: ['ticket'],
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: 'triage',
|
|
23
|
+
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.',
|
|
24
|
+
inputSchema: {
|
|
25
|
+
type: 'object',
|
|
26
|
+
properties: {
|
|
27
|
+
profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
|
|
28
|
+
stale: { type: 'number', description: 'Days before an untouched ticket counts as aging. Defaults to 5.' },
|
|
29
|
+
status: { type: 'array', items: { type: 'string' }, description: 'Statuses to include, overriding the profile default / built-in defaults (In Progress, Code Review, QA).' },
|
|
30
|
+
sort: { type: 'string', description: 'Sort order for results, overriding the profile default.' },
|
|
31
|
+
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.' },
|
|
32
|
+
all: { type: 'boolean', description: 'Triage every configured profile, not just the resolved one. Requires a TicketLens Pro license.' },
|
|
33
|
+
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.' },
|
|
34
|
+
assignee: { type: 'string', description: 'View another user\'s tickets instead of your own. Requires a TicketLens Team license.' },
|
|
35
|
+
sprint: { type: 'string', description: 'Scope to a named sprint. Requires a TicketLens Team license.' },
|
|
36
|
+
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.' },
|
|
37
|
+
project: { type: 'string', description: 'Scope to a project/team key. Requires a TicketLens Team license.' },
|
|
38
|
+
label: { type: 'array', items: { type: 'string' }, description: 'Scope to one or more labels. Requires a TicketLens Team license.' },
|
|
39
|
+
priority: { type: 'string', description: 'Scope to a priority name, e.g. "High". Requires a TicketLens Team license.' },
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'compliance',
|
|
45
|
+
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.',
|
|
46
|
+
inputSchema: {
|
|
47
|
+
type: 'object',
|
|
48
|
+
properties: {
|
|
49
|
+
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
50
|
+
profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
|
|
51
|
+
},
|
|
52
|
+
required: ['ticket'],
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: 'review',
|
|
57
|
+
description: 'Assemble PR review context from the current git branch — changed files, linked-ticket summaries, and (Pro) a requirements-coverage / review-focus section extracted from the diff against acceptance criteria. Read-only; never modifies the tracker or the repo. Free tier gets branch info, changed files, and ticket context; TicketLens Pro adds the requirements-coverage and review-focus sections — same split as the `compliance` tool.',
|
|
58
|
+
inputSchema: {
|
|
59
|
+
type: 'object',
|
|
60
|
+
properties: {
|
|
61
|
+
base: { type: 'string', description: 'Base branch to diff against. Auto-detects main/master/develop when omitted. Alias of `branch` — if both are given, `base` wins.' },
|
|
62
|
+
branch: { type: 'string', description: 'Alias for `base` — same effect, provided for parity with the CLI\'s `--branch=` flag.' },
|
|
63
|
+
profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'standup',
|
|
69
|
+
description: 'Summarize recent git commits grouped by linked ticket — a standup update or, with format:"pr", PR-body-style formatting. Read-only, fully free tier — no license gate on any option.',
|
|
70
|
+
inputSchema: {
|
|
71
|
+
type: 'object',
|
|
72
|
+
properties: {
|
|
73
|
+
since: { type: 'string', description: 'How far back to scan — an integer number of hours ("24") or a git-compatible date expression ("3 days ago"). Defaults to 24 hours.' },
|
|
74
|
+
format: { type: 'string', enum: ['standup', 'pr'], description: 'Output shape: "standup" (default) groups commits under a per-ticket standup update; "pr" renders the same grouped commits as PR-body-style markdown.' },
|
|
75
|
+
profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'pr',
|
|
81
|
+
description: 'Assemble a ready-to-paste PR description for a ticket — what changed (from linked commits), linked tickets, and (if the ticket has acceptance criteria) a requirements-coverage section. Read-only. The requirements-coverage section reuses the same Free-tier 3-checks/month counter as the `compliance` tool — calling `pr` on a ticket with acceptance criteria counts against that shared monthly limit; TicketLens Pro removes the cap.',
|
|
82
|
+
inputSchema: {
|
|
83
|
+
type: 'object',
|
|
84
|
+
properties: {
|
|
85
|
+
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
86
|
+
profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
|
|
87
|
+
},
|
|
88
|
+
required: ['ticket'],
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'stats',
|
|
93
|
+
description: 'Show response-time and triage-cadence metrics from local triage history — average/median response time, clear rate, triage run count, current urgency breakdown. Read-only, entirely local — no network call. Free tier: 7-day lookback max; TicketLens Pro extends it to 30 days.',
|
|
94
|
+
inputSchema: {
|
|
95
|
+
type: 'object',
|
|
96
|
+
properties: {
|
|
97
|
+
profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
|
|
98
|
+
days: { type: 'number', description: 'Lookback window in days. Defaults to 7. Free tier is silently capped at 7; TicketLens Pro allows up to 30.' },
|
|
99
|
+
format: { type: 'string', enum: ['plain', 'json'], description: 'Output shape: "plain" (default) is a human-readable table; "json" is structured for scripting.' },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: 'history',
|
|
105
|
+
description: 'Show a ticket\'s urgency timeline from local triage history — every prior triage scan that surfaced it, with the urgency level and reason computed at that point in time. Read-only, entirely local — no network call. Requires a TicketLens Pro license.',
|
|
106
|
+
inputSchema: {
|
|
107
|
+
type: 'object',
|
|
108
|
+
properties: {
|
|
109
|
+
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
110
|
+
},
|
|
111
|
+
required: ['ticket'],
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: 'collisions',
|
|
116
|
+
description: 'Show branches where your changed files overlap with a teammate\'s — compares your current branch against teammates\' recent branches (within 7 days) pushed via `ticketlens triage --push`. Requires `ticketlens login` (Console access) and a TicketLens Team license.',
|
|
117
|
+
inputSchema: {
|
|
118
|
+
type: 'object',
|
|
119
|
+
properties: {
|
|
120
|
+
json: { type: 'boolean', description: 'Return a raw JSON array of collision objects instead of a formatted report.' },
|
|
121
|
+
plain: { type: 'boolean', description: 'Plain text output with no ANSI colour.' },
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: 'doctor',
|
|
127
|
+
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.',
|
|
128
|
+
inputSchema: {
|
|
129
|
+
type: 'object',
|
|
130
|
+
properties: {
|
|
131
|
+
fix: { type: 'boolean', description: 'Attempt safe, non-destructive repairs for failing checks.' },
|
|
132
|
+
profile: { type: 'string', description: 'Scope profile/connectivity/cache checks to one profile.' },
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: 'recall_add',
|
|
138
|
+
description: 'Save a Recall note — a gotcha, root cause, or non-obvious decision learned this session. Requires a TicketLens Pro license.',
|
|
139
|
+
inputSchema: {
|
|
140
|
+
type: 'object',
|
|
141
|
+
properties: {
|
|
142
|
+
title: { type: 'string', description: 'Short one-line title.' },
|
|
143
|
+
ticket: { type: 'string', description: 'Optional ticket key, e.g. PROJ-123.' },
|
|
144
|
+
tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags derived from this note\'s actual content — the specific technology, error type, root cause, or affected component (e.g. "retry-backoff", "null-pointer", "auth-middleware"). Never the project name or a generic category word like "gotcha" or "bug" — those provide no search signal to someone else looking for this note later. A tag that just restates the title in different words, or one you cannot trace to a specific sentence in the body, gives that same zero signal — if you cannot point to the exact phrase that justifies it, drop it.' },
|
|
145
|
+
body: { type: 'string', description: 'The note body — one or more paragraphs.' },
|
|
146
|
+
},
|
|
147
|
+
required: ['title', 'body'],
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: 'recall_search',
|
|
152
|
+
description: 'Search saved Recall notes by free-text query or ticket key. Requires a TicketLens Pro license.',
|
|
153
|
+
inputSchema: {
|
|
154
|
+
type: 'object',
|
|
155
|
+
properties: {
|
|
156
|
+
query: { type: 'string', description: 'Free-text query or a ticket key like PROJ-123.' },
|
|
157
|
+
},
|
|
158
|
+
required: ['query'],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: 'ticket_comment',
|
|
163
|
+
description: 'Post a comment to a ticket in its tracker (Jira/GitHub/Linear). Destructive — writes directly to the live tracker, not a local Recall note. Requires a TicketLens Pro license.',
|
|
164
|
+
inputSchema: {
|
|
165
|
+
type: 'object',
|
|
166
|
+
properties: {
|
|
167
|
+
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
168
|
+
body: { type: 'string', description: 'Comment body.' },
|
|
169
|
+
attachments: { type: 'array', items: { type: 'string' }, description: 'Local file paths to attach — images render as a real inline thumbnail in the posted comment on both Jira (Cloud and Server/Data Center) and Linear. Not supported on GitHub — no PAT-compatible upload API exists there.' },
|
|
170
|
+
},
|
|
171
|
+
required: ['ticket', 'body'],
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: 'ticket_transition',
|
|
176
|
+
description: 'List or execute a ticket status transition in its tracker (Jira/GitHub/Linear). Called with only `ticket`, lists the tracker\'s current valid options without changing anything. Destructive when `target` and `confirm: true` are both given — requires confirmation and writes directly to the live tracker. Requires a TicketLens Pro license.',
|
|
177
|
+
inputSchema: {
|
|
178
|
+
type: 'object',
|
|
179
|
+
properties: {
|
|
180
|
+
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
181
|
+
target: { type: 'string', description: 'Target status/transition name. Omit to just list the tracker\'s current valid options.' },
|
|
182
|
+
confirm: { type: 'boolean', description: 'Must be true, alongside `target`, to actually execute the transition — a nudge and audit trail, not just a formality.' },
|
|
183
|
+
},
|
|
184
|
+
required: ['ticket'],
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: 'ticket_assign',
|
|
189
|
+
description: 'Assign a ticket to yourself in its tracker (Jira/GitHub/Linear). Self-assign only — assigning to someone else is not supported yet. Requires a TicketLens Pro license.',
|
|
190
|
+
inputSchema: {
|
|
191
|
+
type: 'object',
|
|
192
|
+
properties: {
|
|
193
|
+
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
194
|
+
to: { type: 'string', description: 'Who to assign to — currently only "me" is accepted.' },
|
|
195
|
+
},
|
|
196
|
+
required: ['ticket', 'to'],
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: 'ticket_duplicates',
|
|
201
|
+
description: 'Find likely duplicate tickets in the same project (Jira/GitHub/Linear). Read-only — never links or changes anything. On Jira, any ticket already linked as a "Duplicate" is always included first (a confirmed relationship, not a guess); everything else comes from a local, approximate title/description overlap score, since no tracker scores similarity server-side. That scorer can miss real duplicates as easily as it over-matches, so an empty result means none were found, not a guarantee that none exist. Requires a TicketLens Pro license.',
|
|
202
|
+
inputSchema: {
|
|
203
|
+
type: 'object',
|
|
204
|
+
properties: {
|
|
205
|
+
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
206
|
+
threshold: { type: 'number', description: 'Minimum match score 0-1 to report. Defaults to 0.35.' },
|
|
207
|
+
},
|
|
208
|
+
required: ['ticket'],
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
name: 'ticket_link',
|
|
213
|
+
description: 'List or execute a link between two tickets in their tracker (Jira/GitHub/Linear). Called with only `ticket`/`target`, lists the tracker\'s current valid link types without changing anything. Destructive when `type` and `confirm: true` are both given — writes directly to the live tracker. Direction matters: `ticket` "types" `target` (e.g. ticket duplicates target). On GitHub, executing CLOSES `ticket` as a duplicate of `target` — a state change, not just a relationship add like Jira/Linear. Requires a TicketLens Pro license.',
|
|
214
|
+
inputSchema: {
|
|
215
|
+
type: 'object',
|
|
216
|
+
properties: {
|
|
217
|
+
ticket: { type: 'string', description: 'Source ticket key, e.g. PROJ-123 — the one that "types" target.' },
|
|
218
|
+
target: { type: 'string', description: 'Target ticket key, e.g. PROJ-456.' },
|
|
219
|
+
type: { type: 'string', description: 'Link type name (from the list). Omit to just list the tracker\'s current valid options. GitHub only supports "duplicate".' },
|
|
220
|
+
confirm: { type: 'boolean', description: 'Must be true, alongside `type`, to actually execute the link — a nudge and audit trail, not just a formality.' },
|
|
221
|
+
},
|
|
222
|
+
required: ['ticket', 'target'],
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
name: 'ticket_update',
|
|
227
|
+
description: 'Update a narrow, named field set on a ticket in its tracker (Jira/GitHub/Linear) — title, description, labels, priority. At least one field is required. Labels are add/remove, never a wholesale replace: an unnamed existing label is left alone. No discovery step and no confirm required — these are reversible metadata edits, not workflow-state changes. GitHub has no priority field; passing `priority` for a GitHub-tracked ticket is refused. A call can partially succeed (e.g. title updates but a label does not resolve) — the result reports exactly what landed. Requires a TicketLens Pro license.',
|
|
228
|
+
inputSchema: {
|
|
229
|
+
type: 'object',
|
|
230
|
+
properties: {
|
|
231
|
+
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
232
|
+
title: { type: 'string', description: 'New title/summary. Omit to leave unchanged.' },
|
|
233
|
+
description: { type: 'string', description: 'New description. Omit to leave unchanged.' },
|
|
234
|
+
addLabels: { type: 'array', items: { type: 'string' }, description: 'Labels to add. Existing labels not named here are left alone.' },
|
|
235
|
+
removeLabels: { type: 'array', items: { type: 'string' }, description: 'Labels to remove.' },
|
|
236
|
+
priority: { type: 'string', description: 'New priority name, e.g. "High". Not supported on GitHub.' },
|
|
237
|
+
},
|
|
238
|
+
required: ['ticket'],
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
name: 'ticket_create',
|
|
243
|
+
description: 'Create a new ticket in a tracker (Jira/GitHub/Linear) with a fixed minimal field set — no arbitrary custom fields. Architecturally unlike every other ticket-write tool: there is no existing ticket to target, so the target tracker/project is picked by the connection profile rather than a ticket key. `project` is the Jira project key or Linear team key — required for both, ignored on GitHub (its repo is fixed by the profile). `type` is the Jira issue type — required for Jira only, ignored elsewhere. Highest blast radius of the ticket-write family: a bad project/type fabricates a real, hard-to-walk-back item in a live tracker. Requires a TicketLens Pro license.',
|
|
244
|
+
inputSchema: {
|
|
245
|
+
type: 'object',
|
|
246
|
+
properties: {
|
|
247
|
+
project: { type: 'string', description: 'Jira project key or Linear team key. Required for Jira/Linear; ignored on GitHub.' },
|
|
248
|
+
type: { type: 'string', description: 'Jira issue type, e.g. "Task" or "Bug". Required for Jira only; ignored on GitHub/Linear.' },
|
|
249
|
+
summary: { type: 'string', description: 'Ticket title/summary.' },
|
|
250
|
+
description: { type: 'string', description: 'Ticket description. Omit for none.' },
|
|
251
|
+
attachments: { type: 'array', items: { type: 'string' }, description: 'Local file paths to attach, uploaded after the ticket is created. On Linear the image is automatically linked into the description. On Jira it becomes a real, visible attachment on the issue, but is not embedded inline in the initial description (use ticket_comment afterward for an inline thumbnail). Not supported on GitHub.' },
|
|
252
|
+
profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile. Use this when `project` belongs to a profile other than the one auto-resolved from the current working directory.' },
|
|
253
|
+
},
|
|
254
|
+
required: ['summary'],
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
];
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { queryTicketHistory, DEFAULT_CONFIG_DIR } from './triage-history.mjs';
|
|
2
|
+
import { isLicensed as defaultIsLicensed, showUpgradePrompt } from './license.mjs';
|
|
3
|
+
import { createStyler } from './ansi.mjs';
|
|
4
|
+
import { printHistoryHelp } from './help.mjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Prints a ticket's local triage-history timeline. Extracted from
|
|
8
|
+
* bin/ticketlens.mjs's inline 'history' case so the MCP `history` tool has
|
|
9
|
+
* a CLI-layer function to wrap, same as every other MCP tool (see
|
|
10
|
+
* mcp-server.mjs's file header). Read-only, entirely local — no network call.
|
|
11
|
+
*/
|
|
12
|
+
export async function runHistory(args = [], opts = {}) {
|
|
13
|
+
const print = opts.print ?? ((s) => process.stdout.write(s));
|
|
14
|
+
const warn = opts.warn ?? ((s) => process.stderr.write(s));
|
|
15
|
+
const configDir = opts.configDir ?? DEFAULT_CONFIG_DIR;
|
|
16
|
+
const isLic = opts.isLicensed ?? defaultIsLicensed;
|
|
17
|
+
const queryFn = opts.queryTicketHistoryFn ?? queryTicketHistory;
|
|
18
|
+
|
|
19
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
20
|
+
printHistoryHelp({ stream: { write: print, isTTY: process.stdout.isTTY } });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!isLic('pro', configDir)) {
|
|
25
|
+
showUpgradePrompt('pro', 'ticketlens history', { stream: { write: warn, isTTY: process.stderr.isTTY } });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ticketKey = args[0];
|
|
30
|
+
if (!ticketKey || ticketKey.startsWith('-')) {
|
|
31
|
+
warn('Usage: ticketlens history TICKET-KEY\n');
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const entries = queryFn(ticketKey, { configDir });
|
|
37
|
+
if (entries.length === 0) {
|
|
38
|
+
print(`No triage history found for ${ticketKey}.\n`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const hs = createStyler({ isTTY: process.stdout.isTTY });
|
|
43
|
+
print(`\nHistory for ${hs.bold(ticketKey)} (${entries.length} entries)\n\n`);
|
|
44
|
+
for (const e of entries) {
|
|
45
|
+
const bounce = e.bounced ? hs.yellow(' ⟳ bounced') : '';
|
|
46
|
+
const urg = e.urgency === 'needs-response' ? hs.red(e.urgency) : e.urgency === 'aging' ? hs.yellow(e.urgency) : hs.green(e.urgency);
|
|
47
|
+
print(` ${hs.dim(e.date)} [${e.profile}] ${urg}${bounce} ${hs.dim(e.reason)}\n`);
|
|
48
|
+
}
|
|
49
|
+
print('\n');
|
|
50
|
+
}
|
|
@@ -73,6 +73,11 @@ const HARD_REJECT_PATTERNS = [
|
|
|
73
73
|
|
|
74
74
|
const EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
|
|
75
75
|
|
|
76
|
+
// Shared between CODE_FILENAME_RE below and FILENAME_REFERENCE_RE further
|
|
77
|
+
// down, the same way WHITESPACE_CLASS is shared across the whitespace
|
|
78
|
+
// regexes above — one definition so the two can't silently drift apart.
|
|
79
|
+
const CODE_EXTENSION_ALTERNATION = 'php|m?js|tsx?|jsx|py|rb|java|go|rs|vue|s?css|md|json|ya?ml|sh';
|
|
80
|
+
|
|
76
81
|
// A letters-only token ending in a recognized source-file extension reads as
|
|
77
82
|
// high-entropy the same way a real secret does — a class name doubling as its
|
|
78
83
|
// filename is the common case, but a deliberately-renamed or accidentally
|
|
@@ -86,17 +91,60 @@ const EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
|
|
|
86
91
|
// not a shortcut inside looksRandom itself. A bare identifier with no
|
|
87
92
|
// extension (a method name, not a filename) isn't covered by this at all —
|
|
88
93
|
// that shape is indistinguishable from a base64 secret fragment either way.
|
|
89
|
-
|
|
94
|
+
//
|
|
95
|
+
// Stem allows hyphens (this project's own file-naming convention —
|
|
96
|
+
// "recall-nudge-stop.mjs", "secret-scanner.mjs") captured as group 1, so
|
|
97
|
+
// looksLikeCodeFilename can judge a kebab-case stem the same way
|
|
98
|
+
// isHyphenatedWordCompound already judges one elsewhere in this file (see
|
|
99
|
+
// its call below) — a real multi-word filename reads as short, plausible
|
|
100
|
+
// segments, not one long random run.
|
|
101
|
+
const CODE_FILENAME_RE = new RegExp(`^([A-Za-z][A-Za-z-]*)\\.(${CODE_EXTENSION_ALTERNATION})$`, 'i');
|
|
102
|
+
|
|
103
|
+
// A hyphenated-stem filename (this project's own file-naming convention —
|
|
104
|
+
// "secret-scanner.mjs", "note-command.mjs") optionally followed by a
|
|
105
|
+
// directly-attached possessive apostrophe-s ("note-command.mjs's") stops a
|
|
106
|
+
// joinedChunkRuns run the same way any other label word does (see
|
|
107
|
+
// isLabelWord below). Deliberately a SEPARATE regex from CODE_FILENAME_RE
|
|
108
|
+
// above rather than a loosened version of it: CODE_FILENAME_RE feeds
|
|
109
|
+
// looksLikeCodeFilename's downgrade-a-reject-to-a-warning path and is
|
|
110
|
+
// guarded by hasInternalCaseSwitch specifically so a random single-case
|
|
111
|
+
// string plus a fake extension can't be silently waved through (see its
|
|
112
|
+
// docstring) — loosening that guard is out of scope here and unnecessary:
|
|
113
|
+
// this regex only ever stops a *run*, it never exempts a token from the
|
|
114
|
+
// standalone entropy check (every token stays in the flat `tokens` array
|
|
115
|
+
// in scanForSecrets regardless of isLabelWord), so it cannot itself bypass
|
|
116
|
+
// detection the way a change to CODE_FILENAME_RE could.
|
|
117
|
+
//
|
|
118
|
+
// Known accepted gap, same class as isLabelWord's ordinary-word and
|
|
119
|
+
// hyphenated-compound allowances below: a deliberate attacker could append
|
|
120
|
+
// a fake ".mjs" (optionally + "'s") to the first half of a fragmented
|
|
121
|
+
// secret specifically to stop the join here. Not a new exposure — this
|
|
122
|
+
// file already accepts that an ordinary word or short hyphenated compound
|
|
123
|
+
// can be used the same way (see isLabelWord's "Known accepted gap"
|
|
124
|
+
// comment). HARD_REJECT_PATTERNS are unaffected either way: that pass uses
|
|
125
|
+
// stopAtLabelWords:false and also checks the raw combined/despacedCombined
|
|
126
|
+
// text regardless of any token's label-word status.
|
|
127
|
+
const FILENAME_REFERENCE_RE = new RegExp(`^[A-Za-z][A-Za-z-]*\\.(${CODE_EXTENSION_ALTERNATION})('s)?$`, 'i');
|
|
90
128
|
|
|
91
129
|
function looksLikeCodeFilename(rawToken) {
|
|
92
130
|
const stripped = stripEdgePunctuation(rawToken);
|
|
93
131
|
const match = stripped.match(CODE_FILENAME_RE);
|
|
94
|
-
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
132
|
+
if (match === null) return false;
|
|
133
|
+
// Two independent ways a filename-shaped token reads as "structured, not
|
|
134
|
+
// random" rather than a disguised secret: an internal case switch (the
|
|
135
|
+
// same signal used to detect base64 content elsewhere in this file) is
|
|
136
|
+
// the PascalCase/camelCase signal; isHyphenatedWordCompound (already
|
|
137
|
+
// defined above, already reused this way for the join-stopping check) is
|
|
138
|
+
// the kebab-case signal — this project's own actual file-naming
|
|
139
|
+
// convention. A genuinely random single-case, non-hyphenated letter run
|
|
140
|
+
// plus a fake extension satisfies neither and gets no special treatment
|
|
141
|
+
// at all — only tokens that already look like a real filename (either
|
|
142
|
+
// convention) reach the softer warning path below.
|
|
143
|
+
return hasInternalCaseSwitch(match[0]) || isHyphenatedWordCompound(match[1]);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function looksLikeFilenameReference(strippedToken) {
|
|
147
|
+
return FILENAME_REFERENCE_RE.test(strippedToken);
|
|
100
148
|
}
|
|
101
149
|
|
|
102
150
|
function shannonEntropy(token) {
|
|
@@ -190,13 +238,25 @@ function isHyphenatedWordCompound(token) {
|
|
|
190
238
|
/**
|
|
191
239
|
* True for a token that stops a joined-chunk run: either a recognized git/
|
|
192
240
|
* checksum label word ("commit", "sha256", "md5sum", ...), a hyphenated
|
|
193
|
-
* compound word (see isHyphenatedWordCompound),
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
241
|
+
* compound word (see isHyphenatedWordCompound), a filename reference (see
|
|
242
|
+
* FILENAME_REFERENCE_RE — "note-command.mjs", "note-command.mjs's"), or an
|
|
243
|
+
* ordinary English word (letters only, optionally with an internal
|
|
244
|
+
* possessive/contraction apostrophe — "relay's", "doesn't" — but no
|
|
245
|
+
* base64-style case switching). Anything else — a fragment containing a
|
|
246
|
+
* digit or other symbol, or an all-letter chunk that still reads as random
|
|
247
|
+
* content — stays eligible to join, so a secret split by whitespace can
|
|
248
|
+
* still be reassembled for the entropy check.
|
|
249
|
+
*
|
|
250
|
+
* Filename references (backlog #13) matter for the same reason the
|
|
251
|
+
* apostrophe allowance below does: "note-command.mjs's runNoteAdd" is
|
|
252
|
+
* ordinary engineering prose (a filename possessive next to an identifier),
|
|
253
|
+
* but before this allowance neither token qualified as a label word — the
|
|
254
|
+
* filename fails the ordinary-word branch (hyphen and period aren't
|
|
255
|
+
* letters) and the identifier fails it too (camelCase has an internal case
|
|
256
|
+
* switch, by design) — so they glued into one candidate that tripped the
|
|
257
|
+
* entropy threshold. See FILENAME_REFERENCE_RE's own comment for why this
|
|
258
|
+
* is scoped separately from CODE_FILENAME_RE/looksLikeCodeFilename and
|
|
259
|
+
* cannot reopen that function's CRITICAL-bypass guard.
|
|
200
260
|
*
|
|
201
261
|
* The apostrophe allowance matters because without it, an ordinary possessive
|
|
202
262
|
* next to another non-label token (e.g. a hyphenated compound: "relay's
|
|
@@ -235,6 +295,7 @@ function isLabelWord(token) {
|
|
|
235
295
|
const stripped = stripEdgePunctuation(token);
|
|
236
296
|
if (GIT_REFERENCE_WORD_RE.test(stripped)) return true;
|
|
237
297
|
if (isHyphenatedWordCompound(stripped)) return true;
|
|
298
|
+
if (looksLikeFilenameReference(stripped)) return true;
|
|
238
299
|
return /^[A-Za-z]+(?:'[A-Za-z]+)*$/.test(stripped) && !hasInternalCaseSwitch(stripped);
|
|
239
300
|
}
|
|
240
301
|
|