ticketlens 0.38.33 → 0.38.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +7 -2
- package/skills/jtb/hooks/recall-nudge-lib.mjs +39 -4
- package/skills/jtb/hooks/recall-nudge-stop.mjs +8 -4
- package/skills/jtb/scripts/fetch-ticket.mjs +5 -6
- package/skills/jtb/scripts/lib/api-utils.mjs +1 -1
- package/skills/jtb/scripts/lib/help.mjs +14 -5
- package/skills/jtb/scripts/lib/mcp-server.mjs +95 -4
- package/skills/jtb/scripts/lib/mcp-tool-schemas.mjs +37 -0
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`, `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).
|
|
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`, `ledger`, `doctor`, `recall_add`, `recall_update`, `recall_delete`, `recall_search`, `ticket_comment`, `ticket_transition`, `ticket_assign`, `ticket_duplicates`, `ticket_link`, `ticket_update`, and `ticket_create` as native tools — every CLI action now has an MCP tool, so 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; `ledger` exports the local, signed compliance audit trail (zero network) and requires Pro; every other tool needs Pro. `recall_update` overwrites an existing Recall note's body — internal plumbing for the note quality loop, not typically called directly. `recall_delete` is destructive and local-vault-only — requires `confirm: true` alongside `id` to actually execute; there is no interactive y/N prompt under MCP (no real terminal to prompt against), so omitting it always fails rather than silently blocking. Point your harness's MCP config at it: `{ "command": "ticketlens", "args": ["mcp"] }` — or run `ticketlens mcp install` in a project to write that entry into its `.mcp.json` for you (creates the file if it doesn't exist, merges in if it does — never touches any other entry already there; `--dry-run` to preview first).
|
|
457
457
|
|
|
458
458
|
`note add`'s save confirmation and `recall`'s search results are styled by default in a terminal; add `--plain` to either for bare, pipe-safe output. `recall` always shows each note's file ID (e.g. `[1784135399545-fe01c4.md]`) so you can open it directly (`cat ~/.ticketlens/recall/<PREFIX>/<id>`), or pass `--full` to print the full body content inline instead. Each result shows a relative time (`2h ago`, `3d ago`) rather than a bare date — the full-precision timestamp is always in the note file's own frontmatter.
|
|
459
459
|
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.39.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.
|
|
@@ -66,7 +66,7 @@ Fetches a Jira ticket and produces a structured brief with code references, then
|
|
|
66
66
|
/jtb assign PROD-1234 --to=me # assign the ticket to yourself (Pro)
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
**Destructive commands** (`note delete`, `cloud-keys remove`, `delete <profile>`) prompt for interactive y/N confirmation and refuse outright in a non-interactive shell unless `--yes` is passed — there is no way to silently skip this. Only pass `--yes` when the user has explicitly asked for that specific deletion in this conversation (their message *is* the confirmation); never add it to route around the prompt for a deletion you decided to make on your own.
|
|
69
|
+
**Destructive commands** (`note delete`, `cloud-keys remove`, `delete <profile>`) prompt for interactive y/N confirmation and refuse outright in a non-interactive shell unless `--yes` is passed — there is no way to silently skip this. Only pass `--yes` when the user has explicitly asked for that specific deletion in this conversation (their message *is* the confirmation); never add it to route around the prompt for a deletion you decided to make on your own. If this harness has TicketLens's MCP server configured, `note delete`'s equivalent is the `recall_delete` tool (`id`/`ticket`/`confirm`) — it has no interactive prompt at all (no real terminal exists under MCP to prompt against), so `confirm: true` is the only way it ever executes; the same rule applies — only pass it when the user's message is the confirmation for that specific note.
|
|
70
70
|
|
|
71
71
|
## Prerequisites
|
|
72
72
|
|
|
@@ -370,6 +370,8 @@ When it does apply, run up to 3 rounds:
|
|
|
370
370
|
ticketlens note patch --id="THE-ID-PRINTED-ABOVE" --ticket=TICKET-KEY --expect-mtime="THE-MTIME-FROM-STEP-1"
|
|
371
371
|
```
|
|
372
372
|
`--expect-mtime` is what keeps this safe: if the file changed since step 1 (the user hand-edited it while you were drafting), the patch silently no-ops and prints "not found or already changed" — the user's own edit always wins, never gets clobbered by a stale background draft.
|
|
373
|
+
|
|
374
|
+
If this harness has TicketLens's MCP server configured (a tool named `recall_update` — often shown as `mcp__ticketlens__recall_update` — visible in your tool list), prefer it over the bash form: same license gate, same structural/secret-scan checks, same optimistic-concurrency behavior via `expectMtime` — just no shell command or stdin piping to construct. It accepts `id`/`body`/`ticket`/`expectMtime`.
|
|
373
375
|
5. Repeat from step 1 (re-capture mtime/body fresh each round) up to 3 total rounds. If no round ever produces a fully-passing draft, patch in whichever round scored highest across all attempts, and let the "not found or already changed" message stand if that patch itself loses a late race — don't retry past round 3.
|
|
374
376
|
|
|
375
377
|
This never calls any external API or bills any tokens beyond the session you already have open — the generator and validator are subagents inside your own Claude Code session, not a TicketLens server call.
|
|
@@ -506,6 +508,9 @@ The same tier-gated check also runs as its own command — `ticketlens complianc
|
|
|
506
508
|
|
|
507
509
|
If this harness has TicketLens's MCP server configured (a tool named `compliance` — often shown as `mcp__ticketlens__compliance` — visible in your tool list), prefer it over the bash form: same tier gate (Free: 3 checks/month, Pro: unlimited), same report — just no shell command to construct or stdout to parse. It accepts `ticket`/`profile`, matching the standalone command's arguments above.
|
|
508
510
|
|
|
511
|
+
### Ledger export
|
|
512
|
+
`ticketlens ledger [--format=json|csv]` exports the same local, signed compliance ledger these checks write to — entirely local, no network call. `[Pro]`. If this harness has TicketLens's MCP server configured (a tool named `ledger` — often shown as `mcp__ticketlens__ledger` — visible in your tool list), prefer it over the bash form: same tier gate, same export, just no shell command to construct or stdout to parse. It accepts `format` (`json`/`csv`, defaults to `json`).
|
|
513
|
+
|
|
509
514
|
---
|
|
510
515
|
|
|
511
516
|
## Advanced Options
|
|
@@ -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');
|
|
@@ -170,9 +183,19 @@ export function hasRecentNag(cwd, now = Date.now()) {
|
|
|
170
183
|
* early, and picking one deterministic match keeps this function decoupled
|
|
171
184
|
* from profiles.json (it stays a pure transcript reader; profile lookup
|
|
172
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.
|
|
173
196
|
*/
|
|
174
197
|
export function scanTranscript(transcriptPath) {
|
|
175
|
-
const result = { sawTicketKey: false, sawRecallFlag: false, sawNoteAdd: false, ticketKey: null };
|
|
198
|
+
const result = { sawTicketKey: false, sawRecallFlag: false, sawNoteAdd: false, sawFetch: false, ticketKey: null };
|
|
176
199
|
let lines;
|
|
177
200
|
try {
|
|
178
201
|
lines = fs.readFileSync(transcriptPath, 'utf8').split('\n').filter(Boolean);
|
|
@@ -217,6 +240,10 @@ export function scanTranscript(transcriptPath) {
|
|
|
217
240
|
const isCliNoteAdd = block.name === 'Bash' && NOTE_ADD_RE.test(block.input?.command ?? '');
|
|
218
241
|
const isMcpNoteAdd = NOTE_ADD_MCP_RE.test(block.name ?? '');
|
|
219
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;
|
|
220
247
|
}
|
|
221
248
|
}
|
|
222
249
|
}
|
|
@@ -235,9 +262,17 @@ export function scanTranscript(transcriptPath) {
|
|
|
235
262
|
* compaction/session_id rollover, or nagging more than once per session).
|
|
236
263
|
* Strict's actual effect on capture volume comes from SKILL.md's lowered
|
|
237
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.
|
|
238
273
|
*/
|
|
239
|
-
export function shouldNag({
|
|
240
|
-
if (!
|
|
274
|
+
export function shouldNag({ sawFetch, sawRecallFlag, sawNoteAdd, recallStrictness = 'balanced' }) {
|
|
275
|
+
if (!sawFetch || sawNoteAdd) return false;
|
|
241
276
|
if (recallStrictness === 'loose') return sawRecallFlag; // only the broken-promise case
|
|
242
|
-
return true; // balanced and strict:
|
|
277
|
+
return true; // balanced and strict: a fetch with no note is enough
|
|
243
278
|
}
|
|
@@ -3,12 +3,16 @@
|
|
|
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
|
|
@@ -45,7 +49,7 @@ const cwd = input?.cwd ?? process.cwd();
|
|
|
45
49
|
|
|
46
50
|
if (!sessionId || !transcriptPath) process.exit(0);
|
|
47
51
|
|
|
48
|
-
const {
|
|
52
|
+
const { sawFetch, sawRecallFlag, sawNoteAdd, ticketKey } = scanTranscript(transcriptPath);
|
|
49
53
|
|
|
50
54
|
// Refreshed on every check, independent of the once-per-session gate below —
|
|
51
55
|
// a capture that happens AFTER this session already nagged once must still
|
|
@@ -58,7 +62,7 @@ if (state.stopChecked) process.exit(0); // already asked once this session — r
|
|
|
58
62
|
const profile = resolveProfile(ticketKey, { cwd });
|
|
59
63
|
const recallStrictness = normalizeRecallStrictness(profile?.recallStrictness);
|
|
60
64
|
|
|
61
|
-
if (!shouldNag({
|
|
65
|
+
if (!shouldNag({ sawFetch, sawRecallFlag, sawNoteAdd, recallStrictness })) {
|
|
62
66
|
process.exit(0); // nothing this strictness level requires a capture for
|
|
63
67
|
}
|
|
64
68
|
|
|
@@ -488,10 +488,9 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
488
488
|
}
|
|
489
489
|
|
|
490
490
|
const printFn = opts.print ?? ((chunk) => process.stdout.write(chunk));
|
|
491
|
-
// Mirrors printFn above — the bare ticket-fetch, compliance, pr, review, and
|
|
492
|
-
// paths all use this now (each has an MCP tool). `
|
|
493
|
-
//
|
|
494
|
-
// gets one.
|
|
491
|
+
// Mirrors printFn above — the bare ticket-fetch, compliance, pr, review, standup, and
|
|
492
|
+
// ledger paths all use this now (each has an MCP tool). `install-hooks` is CLI-only
|
|
493
|
+
// and never gets one.
|
|
495
494
|
const printErrFn = opts.printErr ?? ((chunk) => process.stderr.write(chunk));
|
|
496
495
|
// Reused on every recursive self-call in the bare-fetch path (profile-prompt retries)
|
|
497
496
|
// so an injected print/printErr survives the retry instead of silently reverting to
|
|
@@ -610,7 +609,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
610
609
|
const { isLicensed: isLic, showUpgradePrompt: showUpgrade } = await import('./lib/license.mjs');
|
|
611
610
|
const resolvedConfigDir = configDir ?? (await import('./lib/config.mjs')).DEFAULT_CONFIG_DIR;
|
|
612
611
|
if (!isLic('pro', resolvedConfigDir)) {
|
|
613
|
-
showUpgrade('pro', 'ledger', { stream:
|
|
612
|
+
showUpgrade('pro', 'ledger', { stream: errStream });
|
|
614
613
|
process.exitCode = 1;
|
|
615
614
|
return;
|
|
616
615
|
}
|
|
@@ -621,7 +620,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
621
620
|
printFn(result + '\n');
|
|
622
621
|
} else {
|
|
623
622
|
printFn(JSON.stringify(result, null, 2) + '\n');
|
|
624
|
-
|
|
623
|
+
printErrFn(' Verify signature: HMAC-SHA256 over {records, exportedAt} with key at ledger-key\n');
|
|
625
624
|
}
|
|
626
625
|
return;
|
|
627
626
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Centralised here to avoid triplicating the regex and warning logic.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
export const DEFAULT_API_BASE = '
|
|
6
|
+
export const DEFAULT_API_BASE = 'http://api.ticketlens.test';
|
|
7
7
|
export const DEFAULT_SITE_BASE = 'https://ticketlens.app';
|
|
8
8
|
|
|
9
9
|
// Matches localhost, 127.0.0.1, and any hostname ending in .test or .local,
|
|
@@ -646,11 +646,14 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
|
646
646
|
'',
|
|
647
647
|
` Start an MCP (Model Context Protocol) stdio server exposing CLI actions as`,
|
|
648
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('
|
|
650
|
-
` ${s.cyan('
|
|
651
|
-
`
|
|
652
|
-
`
|
|
653
|
-
` ${s.cyan('
|
|
649
|
+
` ${s.cyan('collisions')}, ${s.cyan('ledger')}, ${s.cyan('doctor')}, ${s.cyan('recall_add')}, ${s.cyan('recall_update')}, ${s.cyan('recall_delete')},`,
|
|
650
|
+
` ${s.cyan('recall_search')}, ${s.cyan('ticket_comment')}, ${s.cyan('ticket_transition')}, ${s.cyan('ticket_assign')},`,
|
|
651
|
+
` ${s.cyan('ticket_duplicates')}, ${s.cyan('ticket_link')}, ${s.cyan('ticket_update')}, ${s.cyan('ticket_create')} — every CLI action`,
|
|
652
|
+
` now has an MCP tool, for any MCP-compatible AI harness, not just Claude Code.`,
|
|
653
|
+
` Thin adapter over the same code as ${s.cyan('TICKET-KEY')}/${s.cyan('doctor')}/${s.cyan('triage')}/${s.cyan('compliance')}/${s.cyan('review')}/`,
|
|
654
|
+
` ${s.cyan('standup')}/${s.cyan('pr')}/${s.cyan('stats')}/${s.cyan('history')}/${s.cyan('collisions')}/${s.cyan('ledger')}/${s.cyan('note add')}/${s.cyan('note patch')}/`,
|
|
655
|
+
` ${s.cyan('note delete')}/${s.cyan('recall')}/${s.cyan('comment')}/${s.cyan('transition')}/${s.cyan('assign')}/${s.cyan('duplicates')}/${s.cyan('link')}/`,
|
|
656
|
+
` ${s.cyan('update')}/${s.cyan('create')} above.`,
|
|
654
657
|
` ${s.cyan('fetch')}, ${s.cyan('doctor')}, and ${s.cyan('standup')} are Free; ${s.cyan('triage')} is Free with some Pro/Team-gated`,
|
|
655
658
|
` options (see ${s.cyan('ticketlens triage --help')}); ${s.cyan('compliance')} and ${s.cyan('pr')} are Free, sharing a`,
|
|
656
659
|
` 3-checks/month cap on their requirements-coverage section, Pro unlimited;`,
|
|
@@ -666,6 +669,12 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
|
666
669
|
` ${s.cyan('ticket_update')} has no priority field on GitHub and can partially succeed;`,
|
|
667
670
|
` ${s.cyan('ticket_create')} has no ticket key to target — --profile/the default profile picks the`,
|
|
668
671
|
` tracker, and it fabricates a real item, the highest blast radius of this family.`,
|
|
672
|
+
` ${s.cyan('ledger')} exports the local, signed compliance audit trail — entirely local, no`,
|
|
673
|
+
` network call. ${s.cyan('recall_update')} overwrites an existing Recall note's body — internal`,
|
|
674
|
+
` plumbing for the note quality loop, not typically called directly.`,
|
|
675
|
+
` ${s.cyan('recall_delete')} is destructive and local-vault-only — requires \`confirm: true\`,`,
|
|
676
|
+
` alongside \`id\`, to actually execute; there is no interactive y/N prompt under`,
|
|
677
|
+
` MCP, so omitting it always fails rather than silently blocking.`,
|
|
669
678
|
` Long-running — exits when the client closes stdin.`,
|
|
670
679
|
'',
|
|
671
680
|
` ${s.dim('If a tool call rejects a parameter you expect right after upgrading ticketlens,')}`,
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
import readline from 'node:readline';
|
|
23
23
|
import { DEFAULT_CONFIG_DIR, getVersion } from './config.mjs';
|
|
24
24
|
import { runDoctor } from './doctor-command.mjs';
|
|
25
|
-
import { runNoteAdd } from './note-command.mjs';
|
|
25
|
+
import { runNoteAdd, runNotePatch, runNoteDelete } from './note-command.mjs';
|
|
26
26
|
import { runRecall } from './recall-command.mjs';
|
|
27
27
|
import { runTicketComment, runTicketTransitionList, runTicketTransition, runTicketAssign, runTicketDuplicates, runTicketLinkList, runTicketLink, runTicketUpdate, runTicketCreate } from './ticket-command.mjs';
|
|
28
28
|
import { run as runFetchTicket } from '../fetch-ticket.mjs';
|
|
@@ -239,6 +239,25 @@ async function callPr(args, deps) {
|
|
|
239
239
|
return callFetchTicketRun(buildPrArgs, args, deps, 'pr failed');
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
/**
|
|
243
|
+
* `ledger` is a subcommand of the same fetch-ticket.mjs `run()` that
|
|
244
|
+
* `callFetch`/`callCompliance`/`callPr` already wrap — reuses `runFetchTicketFn`,
|
|
245
|
+
* no new dependency. Unlike those, it has no `ticket`/`profile` argument — the
|
|
246
|
+
* ledger is local and config-dir scoped, not per-ticket. Its two direct
|
|
247
|
+
* `process.stderr` writes (the license-gate upgrade prompt and the
|
|
248
|
+
* verify-signature note printed alongside a successful json export) were
|
|
249
|
+
* threaded through opts.printErr as part of adding this tool.
|
|
250
|
+
*/
|
|
251
|
+
function buildLedgerArgs({ format }) {
|
|
252
|
+
const args = ['ledger'];
|
|
253
|
+
if (format) args.push(`--format=${format}`);
|
|
254
|
+
return args;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function callLedger(args, deps) {
|
|
258
|
+
return callFetchTicketRun(buildLedgerArgs, args, deps, 'ledger export failed');
|
|
259
|
+
}
|
|
260
|
+
|
|
242
261
|
function buildDoctorArgs({ fix, profile }) {
|
|
243
262
|
const args = ['--format=json'];
|
|
244
263
|
if (fix === true) args.push('--fix');
|
|
@@ -355,6 +374,68 @@ async function callRecallAdd(args, { configDir, runNoteAddFn }) {
|
|
|
355
374
|
return written ? { content } : { isError: true, content };
|
|
356
375
|
}
|
|
357
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Builds runNotePatch's cmdArgs array — same single-opaque-element reasoning
|
|
379
|
+
* as buildNoteAddArgs above. `expectMtime` is optimistic-concurrency: a
|
|
380
|
+
* caller that fetched a note via recall_search and wants to refine it
|
|
381
|
+
* without racing a concurrent edit passes back the mtime it observed.
|
|
382
|
+
*/
|
|
383
|
+
function buildNotePatchArgs({ id, ticket, expectMtime }) {
|
|
384
|
+
const args = [`--id=${id}`];
|
|
385
|
+
if (ticket) args.push(`--ticket=${ticket}`);
|
|
386
|
+
if (expectMtime !== undefined) args.push(`--expect-mtime=${expectMtime}`);
|
|
387
|
+
return args;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function callRecallUpdate(args, { configDir, runNotePatchFn }) {
|
|
391
|
+
if (!args.id) {
|
|
392
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: id' }] };
|
|
393
|
+
}
|
|
394
|
+
if (!args.body) {
|
|
395
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: body' }] };
|
|
396
|
+
}
|
|
397
|
+
const capture = capturingStream();
|
|
398
|
+
const { patched } = await runNotePatchFn(buildNotePatchArgs(args), {
|
|
399
|
+
configDir,
|
|
400
|
+
stream: capture,
|
|
401
|
+
readStdin: async () => args.body,
|
|
402
|
+
});
|
|
403
|
+
const content = [{ type: 'text', text: capture.text }];
|
|
404
|
+
return patched ? { content } : { isError: true, content };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Builds runNoteDelete's cmdArgs array — same single-opaque-element reasoning
|
|
409
|
+
* as buildNoteAddArgs above. Always passes `--yes`: runNoteDelete's own
|
|
410
|
+
* confirmDestructive gate refuses outright in non-interactive mode (no real
|
|
411
|
+
* TTY exists under the MCP transport to prompt against), so without it every
|
|
412
|
+
* call would fail. The caller's `confirm: true` is enforced in callRecallDelete
|
|
413
|
+
* below instead, before runNoteDeleteFn is ever reached — same nudge-and-audit
|
|
414
|
+
* -trail spirit as ticket_transition/ticket_link, but deliberately a different
|
|
415
|
+
* code path: those two defer the refusal to the wrapped CLI function, which
|
|
416
|
+
* here would surface as that generic non-interactive error instead of a
|
|
417
|
+
* dedicated one naming `confirm`.
|
|
418
|
+
*/
|
|
419
|
+
function buildNoteDeleteArgs({ id, ticket }) {
|
|
420
|
+
const args = [`--id=${id}`];
|
|
421
|
+
if (ticket) args.push(`--ticket=${ticket}`);
|
|
422
|
+
args.push('--yes');
|
|
423
|
+
return args;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function callRecallDelete(args, { configDir, runNoteDeleteFn }) {
|
|
427
|
+
if (!args.id) {
|
|
428
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: id' }] };
|
|
429
|
+
}
|
|
430
|
+
if (args.confirm !== true) {
|
|
431
|
+
return { isError: true, content: [{ type: 'text', text: 'Deletion requires confirm: true — this cannot be restored.' }] };
|
|
432
|
+
}
|
|
433
|
+
const capture = capturingStream();
|
|
434
|
+
const { deleted } = await runNoteDeleteFn(buildNoteDeleteArgs(args), { configDir, stream: capture });
|
|
435
|
+
const content = [{ type: 'text', text: capture.text }];
|
|
436
|
+
return deleted ? { content } : { isError: true, content };
|
|
437
|
+
}
|
|
438
|
+
|
|
358
439
|
async function callRecallSearch(args, { configDir, runRecallFn }) {
|
|
359
440
|
const capture = capturingStream();
|
|
360
441
|
const { ok } = await runRecallFn([args.query ?? ''], {
|
|
@@ -532,11 +613,14 @@ async function handleToolsCall(params, deps) {
|
|
|
532
613
|
if (name === 'review') return callReview(args, deps);
|
|
533
614
|
if (name === 'standup') return callStandup(args, deps);
|
|
534
615
|
if (name === 'pr') return callPr(args, deps);
|
|
616
|
+
if (name === 'ledger') return callLedger(args, deps);
|
|
535
617
|
if (name === 'doctor') return callDoctor(args, deps);
|
|
536
618
|
if (name === 'stats') return callStats(args, deps);
|
|
537
619
|
if (name === 'history') return callHistory(args, deps);
|
|
538
620
|
if (name === 'collisions') return callCollisions(args, deps);
|
|
539
621
|
if (name === 'recall_add') return callRecallAdd(args, deps);
|
|
622
|
+
if (name === 'recall_update') return callRecallUpdate(args, deps);
|
|
623
|
+
if (name === 'recall_delete') return callRecallDelete(args, deps);
|
|
540
624
|
if (name === 'recall_search') return callRecallSearch(args, deps);
|
|
541
625
|
if (name === 'ticket_comment') return callTicketComment(args, deps);
|
|
542
626
|
if (name === 'ticket_transition') return callTicketTransition(args, deps);
|
|
@@ -548,7 +632,7 @@ async function handleToolsCall(params, deps) {
|
|
|
548
632
|
return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
|
|
549
633
|
}
|
|
550
634
|
|
|
551
|
-
async function handleMessage(raw,
|
|
635
|
+
async function handleMessage(raw, deps) {
|
|
552
636
|
let msg;
|
|
553
637
|
try {
|
|
554
638
|
msg = JSON.parse(raw);
|
|
@@ -578,7 +662,7 @@ async function handleMessage(raw, { configDir, runFetchTicketFn, runTriageFn, ru
|
|
|
578
662
|
|
|
579
663
|
if (method === 'tools/call') {
|
|
580
664
|
try {
|
|
581
|
-
const result = await handleToolsCall(params,
|
|
665
|
+
const result = await handleToolsCall(params, deps);
|
|
582
666
|
return jsonRpcResult(id, result);
|
|
583
667
|
} catch (err) {
|
|
584
668
|
return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
|
|
@@ -606,6 +690,8 @@ export function runMcpServer({
|
|
|
606
690
|
runHistoryFn = runHistory,
|
|
607
691
|
runCollisionsFn = runCollisions,
|
|
608
692
|
runNoteAddFn = runNoteAdd,
|
|
693
|
+
runNotePatchFn = runNotePatch,
|
|
694
|
+
runNoteDeleteFn = runNoteDelete,
|
|
609
695
|
runRecallFn = runRecall,
|
|
610
696
|
runTicketCommentFn = runTicketComment,
|
|
611
697
|
runTicketTransitionListFn = runTicketTransitionList,
|
|
@@ -624,6 +710,11 @@ export function runMcpServer({
|
|
|
624
710
|
stdin.on('error', () => {});
|
|
625
711
|
stdout.on('error', () => {});
|
|
626
712
|
|
|
713
|
+
// Assembled once and passed straight through handleMessage to handleToolsCall,
|
|
714
|
+
// which is the only place the individual functions are read — so a new tool
|
|
715
|
+
// needs its dependency named here and in the parameter list above, nowhere else.
|
|
716
|
+
const deps = { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runStatsFn, runHistoryFn, runCollisionsFn, runNoteAddFn, runNotePatchFn, runNoteDeleteFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn };
|
|
717
|
+
|
|
627
718
|
const rl = readline.createInterface({ input: stdin, terminal: false });
|
|
628
719
|
let queue = Promise.resolve();
|
|
629
720
|
|
|
@@ -635,7 +726,7 @@ export function runMcpServer({
|
|
|
635
726
|
// never resolving (a dropped rejection isn't a resolution) — the
|
|
636
727
|
// server would hang on shutdown instead of exiting.
|
|
637
728
|
queue = queue.then(async () => {
|
|
638
|
-
const response = await handleMessage(line,
|
|
729
|
+
const response = await handleMessage(line, deps);
|
|
639
730
|
if (response) stdout.write(response);
|
|
640
731
|
}).catch(() => {});
|
|
641
732
|
});
|
|
@@ -88,6 +88,16 @@ export const TOOLS = [
|
|
|
88
88
|
required: ['ticket'],
|
|
89
89
|
},
|
|
90
90
|
},
|
|
91
|
+
{
|
|
92
|
+
name: 'ledger',
|
|
93
|
+
description: 'Export the local compliance ledger — a signed, tamper-evident record of every compliance check run (ticket key, commit SHA, author, timestamp, coverage %). Read-only, entirely local — no network call. Verifiable offline: the json format includes an HMAC-SHA256 signature over {records, exportedAt}, keyed at a local ledger-key file. Requires a TicketLens Pro license.',
|
|
94
|
+
inputSchema: {
|
|
95
|
+
type: 'object',
|
|
96
|
+
properties: {
|
|
97
|
+
format: { type: 'string', enum: ['json', 'csv'], description: 'Output shape: "json" (default) includes the HMAC signature; "csv" is a flat export with no signature.' },
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
91
101
|
{
|
|
92
102
|
name: 'stats',
|
|
93
103
|
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.',
|
|
@@ -147,6 +157,33 @@ export const TOOLS = [
|
|
|
147
157
|
required: ['title', 'body'],
|
|
148
158
|
},
|
|
149
159
|
},
|
|
160
|
+
{
|
|
161
|
+
name: 'recall_update',
|
|
162
|
+
description: 'Overwrite an existing Recall note\'s body with a better draft — internal plumbing for the jtb skill\'s note quality loop, not typically called directly. The new body gets the same structural and secret-scan checks a user-typed body gets. Local vault only. Requires a TicketLens Pro license.',
|
|
163
|
+
inputSchema: {
|
|
164
|
+
type: 'object',
|
|
165
|
+
properties: {
|
|
166
|
+
id: { type: 'string', description: 'Note id to patch, as printed by recall_add or recall_search.' },
|
|
167
|
+
body: { type: 'string', description: 'The replacement note body.' },
|
|
168
|
+
ticket: { type: 'string', description: 'Ticket key the note is about, e.g. PROJ-123. Optional — narrows the search when omitted, the vault is searched across all ticket prefixes.' },
|
|
169
|
+
expectMtime: { type: 'number', description: 'The note file\'s mtime (ms) last observed by the caller. If the note changed since then, the patch is a no-op rather than an overwrite — optimistic concurrency, not a hard requirement.' },
|
|
170
|
+
},
|
|
171
|
+
required: ['id', 'body'],
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: 'recall_delete',
|
|
176
|
+
description: 'Delete a Recall note from the local vault. Irreversible locally, and destructive — requires `confirm: true` alongside `id` to actually execute; there is no interactive y/N prompt under MCP (no real terminal exists to prompt against), so omitting confirm always fails rather than silently blocking. Local vault only — if this note 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). Requires a TicketLens Pro license.',
|
|
177
|
+
inputSchema: {
|
|
178
|
+
type: 'object',
|
|
179
|
+
properties: {
|
|
180
|
+
id: { type: 'string', description: 'Note id to delete, as printed by recall_add or recall_search.' },
|
|
181
|
+
ticket: { type: 'string', description: 'Ticket key the note is about, e.g. PROJ-123. Optional — narrows the search when omitted, the vault is searched across all ticket prefixes.' },
|
|
182
|
+
confirm: { type: 'boolean', description: 'Must be true to actually execute the deletion — a nudge and audit trail, not just a formality. Omitted or false always fails.' },
|
|
183
|
+
},
|
|
184
|
+
required: ['id'],
|
|
185
|
+
},
|
|
186
|
+
},
|
|
150
187
|
{
|
|
151
188
|
name: 'recall_search',
|
|
152
189
|
description: 'Search saved Recall notes by free-text query or ticket key. Requires a TicketLens Pro license.',
|