ticketlens 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -54,6 +54,9 @@ export function printHelp({ stream = process.stdout } = {}) {
54
54
  ` ${s.brand('ticketlens')} recall sync Retry any notes stuck in the local queue ${s.dim('[Team+]')}`,
55
55
  ` ${s.brand('ticketlens')} recall settings Show effective retry-queue settings, fetched live ${s.dim('[Team+]')}`,
56
56
  ` ${s.brand('ticketlens')} mcp Start the MCP stdio server for Recall ${s.dim('[Pro]')}`,
57
+ ` ${s.brand('ticketlens')} comment ${s.dim('<TICKET-KEY> --body=...')} Post a comment to the tracker ${s.dim('[Pro]')}`,
58
+ ` ${s.brand('ticketlens')} transition ${s.dim('<TICKET-KEY> [--target=... --confirm]')} Move ticket status ${s.dim('[Pro]')}`,
59
+ ` ${s.brand('ticketlens')} assign ${s.dim('<TICKET-KEY> --to=me')} Assign a ticket to yourself ${s.dim('[Pro]')}`,
57
60
  '',
58
61
  ` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
59
62
  ` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
@@ -592,11 +595,14 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
592
595
  '',
593
596
  ` ${s.bold(s.brand('ticketlens'))} ${s.bold('mcp')} ${s.dim('[Pro]')}`,
594
597
  '',
595
- ` Start an MCP (Model Context Protocol) stdio server exposing Recall as`,
596
- ` native tools — ${s.cyan('recall_add')} and ${s.cyan('recall_search')} — for any MCP-compatible AI`,
597
- ` harness, not just Claude Code. Thin adapter over the same code as`,
598
- ` ${s.cyan('note add')}/${s.cyan('recall')} above: same Pro gate, same secret scan, same local`,
599
- ` vault, same team sync. Long-running — exits when the client closes stdin.`,
598
+ ` Start an MCP (Model Context Protocol) stdio server exposing Recall and`,
599
+ ` ticket writes as native tools — ${s.cyan('recall_add')}, ${s.cyan('recall_search')}, ${s.cyan('ticket_comment')},`,
600
+ ` ${s.cyan('ticket_transition')}, ${s.cyan('ticket_assign')} — for any MCP-compatible AI harness, not just`,
601
+ ` Claude Code. Thin adapter over the same code as ${s.cyan('note add')}/${s.cyan('recall')}/${s.cyan('comment')}/`,
602
+ ` ${s.cyan('transition')}/${s.cyan('assign')} above: same Pro gate, same local vault/tracker writes, same`,
603
+ ` team sync. ${s.cyan('ticket_transition')} is destructive when called with \`target\`+\`confirm: true\`;`,
604
+ ` ${s.cyan('ticket_assign')} is currently self-assign only.`,
605
+ ` Long-running — exits when the client closes stdin.`,
600
606
  '',
601
607
  ` ${s.bold('OPTIONS')}`,
602
608
  '',
@@ -623,6 +629,79 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
623
629
  stream.write(lines.join('\n') + '\n');
624
630
  }
625
631
 
632
+ export function printCommentHelp({ stream = process.stdout } = {}) {
633
+ const s = createStyler({ isTTY: stream.isTTY });
634
+ const lines = [
635
+ '',
636
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('comment')} ${s.dim('TICKET-KEY --body="..."')} ${s.dim('[Pro]')}`,
637
+ '',
638
+ ` Post a comment directly to the ticket in its tracker (Jira/GitHub/Linear). ${s.dim('[Pro]')}`,
639
+ ` Writes to the real tracker — this is not a local Recall note.`,
640
+ '',
641
+ ` ${s.bold('OPTIONS')}`,
642
+ '',
643
+ ` ${s.brand('--body')}=${s.dim('TEXT')} Comment body ${s.dim('(required)')}`,
644
+ ` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
645
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
646
+ '',
647
+ ` ${s.bold('EXAMPLES')}`,
648
+ '',
649
+ ` ${s.dim('$')} ticketlens comment PROD-123 --body="Looks good, merging."`,
650
+ '',
651
+ ];
652
+ stream.write(lines.join('\n') + '\n');
653
+ }
654
+
655
+ export function printTransitionHelp({ stream = process.stdout } = {}) {
656
+ const s = createStyler({ isTTY: stream.isTTY });
657
+ const lines = [
658
+ '',
659
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('transition')} ${s.dim('TICKET-KEY [--target="..." --confirm]')} ${s.dim('[Pro]')}`,
660
+ '',
661
+ ` Move a ticket to a new status in its tracker (Jira/GitHub/Linear). ${s.dim('[Pro]')}`,
662
+ ` Called with just a ticket key, lists the tracker's current valid options`,
663
+ ` without changing anything. Add ${s.brand('--target')} and ${s.brand('--confirm')} together to execute.`,
664
+ '',
665
+ ` ${s.bold('OPTIONS')}`,
666
+ '',
667
+ ` ${s.brand('--target')}=${s.dim('NAME')} Target status/transition name ${s.dim('(from the list, case-insensitive)')}`,
668
+ ` ${s.brand('--confirm')} Required alongside --target to actually execute`,
669
+ ` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
670
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
671
+ '',
672
+ ` ${s.bold('EXAMPLES')}`,
673
+ '',
674
+ ` ${s.dim('$')} ticketlens transition PROD-123`,
675
+ ` ${s.dim('$')} ticketlens transition PROD-123 --target="Done" --confirm`,
676
+ '',
677
+ ];
678
+ stream.write(lines.join('\n') + '\n');
679
+ }
680
+
681
+ export function printAssignHelp({ stream = process.stdout } = {}) {
682
+ const s = createStyler({ isTTY: stream.isTTY });
683
+ const lines = [
684
+ '',
685
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('assign')} ${s.dim('TICKET-KEY --to=me')} ${s.dim('[Pro]')}`,
686
+ '',
687
+ ` Assign a ticket to yourself directly in its tracker (Jira/GitHub/Linear). ${s.dim('[Pro]')}`,
688
+ ` Self-assign only for now — ${s.brand('--to')} must be ${s.brand('me')}. Assigning to someone else`,
689
+ ` isn't supported yet.`,
690
+ '',
691
+ ` ${s.bold('OPTIONS')}`,
692
+ '',
693
+ ` ${s.brand('--to')}=${s.dim('me')} Required — only "me" is currently supported`,
694
+ ` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
695
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
696
+ '',
697
+ ` ${s.bold('EXAMPLES')}`,
698
+ '',
699
+ ` ${s.dim('$')} ticketlens assign PROD-123 --to=me`,
700
+ '',
701
+ ];
702
+ stream.write(lines.join('\n') + '\n');
703
+ }
704
+
626
705
  export function printSwitchHelp({ stream = process.stdout } = {}) {
627
706
  const s = createStyler({ isTTY: stream.isTTY });
628
707
  const lines = [
@@ -4,7 +4,7 @@
4
4
  * Supports v2 (Server/DC) and v3 (Cloud) API versions.
5
5
  */
6
6
 
7
- import { adfToText } from './adf-converter.mjs';
7
+ import { adfToText, textToAdf } from './adf-converter.mjs';
8
8
  import { lookup as dnsLookup } from 'node:dns/promises';
9
9
 
10
10
  function toText(value) {
@@ -408,6 +408,122 @@ export async function fetchRemoteLinks(ticketKey, opts = {}) {
408
408
  .map(link => ({ url: link.object.url, title: link.object.title ?? null }));
409
409
  }
410
410
 
411
+ /**
412
+ * Adds a comment to an issue. Cloud (v3) rejects a plain string body
413
+ * outright and requires ADF; Server/DC (v2) accepts plain text directly —
414
+ * same apiVersion branch point every other write/read here already uses.
415
+ */
416
+ export async function postComment(ticketKey, body, opts = {}) {
417
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
418
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
419
+ const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
420
+ const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/comment`;
421
+
422
+ const payload = { body: apiVersion === 3 ? textToAdf(body) : body };
423
+ const fetchOpts = {
424
+ method: 'POST',
425
+ headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' },
426
+ body: JSON.stringify(payload),
427
+ };
428
+ if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
429
+
430
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
431
+ if (!response.ok) {
432
+ const err = new Error(`Jira API error ${response.status} commenting on ${ticketKey}`);
433
+ err.status = response.status;
434
+ throw err;
435
+ }
436
+ const raw = await response.json();
437
+ return { id: raw.id, url: raw.self ?? null };
438
+ }
439
+
440
+ /**
441
+ * Discovers the transitions actually available for this specific issue
442
+ * right now (workflow-dependent, varies per project/status) — never
443
+ * hardcode transition names or ids, they aren't stable across projects.
444
+ */
445
+ export async function getTransitions(ticketKey, opts = {}) {
446
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
447
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
448
+ const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
449
+ const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/transitions`;
450
+
451
+ const fetchOpts = { headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' } };
452
+ if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
453
+
454
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
455
+ if (!response.ok) {
456
+ const err = new Error(`Jira API error ${response.status} fetching transitions for ${ticketKey}`);
457
+ err.status = response.status;
458
+ throw err;
459
+ }
460
+ const raw = await response.json();
461
+ return (raw.transitions ?? []).map(t => ({ id: t.id, name: t.name, to: t.to?.name ?? null }));
462
+ }
463
+
464
+ /**
465
+ * Executes a transition by id. Callers must resolve the id via
466
+ * getTransitions() immediately before calling this — never pass a
467
+ * remembered/stale id, since transitions are workflow- and
468
+ * time-of-status-dependent (see ticket-command.mjs's re-validation).
469
+ * A transition requiring a mandatory screen field the caller didn't
470
+ * supply returns Jira's own 400 with the field list — surfaced as-is,
471
+ * never silently swallowed or auto-filled.
472
+ */
473
+ export async function postTransition(ticketKey, transitionId, opts = {}) {
474
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
475
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
476
+ const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
477
+ const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/transitions`;
478
+
479
+ const fetchOpts = {
480
+ method: 'POST',
481
+ headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' },
482
+ body: JSON.stringify({ transition: { id: transitionId } }),
483
+ };
484
+ if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
485
+
486
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
487
+ if (!response.ok) {
488
+ let details;
489
+ try { details = await response.json(); } catch { /* body not JSON — fall through with no details */ }
490
+ const err = new Error(`Jira API error ${response.status} transitioning ${ticketKey}`);
491
+ err.status = response.status;
492
+ err.details = details;
493
+ throw err;
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Sets the issue's assignee. `assignee` is sent verbatim — the caller
499
+ * (jira-adapter.mjs) resolves the right shape for the API version:
500
+ * `{ accountId }` for Cloud (v3), `{ name }` for Server/DC (v2), since
501
+ * Cloud has no username concept and Server/DC has no accountId.
502
+ */
503
+ export async function assignIssue(ticketKey, assignee, opts = {}) {
504
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
505
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
506
+ const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
507
+ const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/assignee`;
508
+
509
+ const fetchOpts = {
510
+ method: 'PUT',
511
+ headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' },
512
+ body: JSON.stringify(assignee),
513
+ };
514
+ if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
515
+
516
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
517
+ if (!response.ok) {
518
+ let details;
519
+ try { details = await response.json(); } catch { /* body not JSON — fall through with no details */ }
520
+ const err = new Error(`Jira API error ${response.status} assigning ${ticketKey}`);
521
+ err.status = response.status;
522
+ err.details = details;
523
+ throw err;
524
+ }
525
+ }
526
+
411
527
  export async function fetchTicket(ticketKey, opts = {}) {
412
528
  const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), depth = 1, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, allowPrivateIp = false, _visited = new Set(), _currentDepth = 0 } = opts;
413
529
  validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
@@ -3,12 +3,15 @@
3
3
  *
4
4
  * A pure transport adapter: parses JSON-RPC 2.0 off stdin, translates
5
5
  * `tools/call` arguments into the exact args/dependency shape `runNoteAdd`/
6
- * `runRecall` already accept, and captures their human-readable `stream`
7
- * output into the JSON-RPC response instead of a real stream. Zero new
8
- * validation, licensing, or vault logic — both tools funnel through the
9
- * same functions the CLI's `note add`/`recall` commands already use, so
10
- * every existing gate (license, secret scan, structural check, retry
11
- * queue) applies identically here.
6
+ * `runRecall`/`runTicketComment`/`runTicketTransitionList`/
7
+ * `runTicketTransition` already accept, and captures their human-readable
8
+ * `stream` output into the JSON-RPC response instead of a real stream. Zero
9
+ * new validation, licensing, or vault logic — every tool funnels through
10
+ * the same functions the CLI's `note add`/`recall`/`comment`/`transition`
11
+ * commands already use, so every existing gate (license, secret scan,
12
+ * structural check, retry queue, cooldown, audit log) applies identically
13
+ * here. Ticket-writing tools must never import an adapter directly — doing
14
+ * so would fully bypass the Pro+ gate that lives in ticket-command.mjs.
12
15
  *
13
16
  * Per the MCP stdio transport spec, the server MUST NOT write anything to
14
17
  * stdout that isn't a valid MCP message — every wrapped function's output
@@ -20,6 +23,7 @@ import readline from 'node:readline';
20
23
  import { DEFAULT_CONFIG_DIR, getVersion } from './config.mjs';
21
24
  import { runNoteAdd } from './note-command.mjs';
22
25
  import { runRecall } from './recall-command.mjs';
26
+ import { runTicketComment, runTicketTransitionList, runTicketTransition, runTicketAssign } from './ticket-command.mjs';
23
27
 
24
28
  const PROTOCOL_VERSION = '2025-11-25';
25
29
 
@@ -49,6 +53,43 @@ const TOOLS = [
49
53
  required: ['query'],
50
54
  },
51
55
  },
56
+ {
57
+ name: 'ticket_comment',
58
+ 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.',
59
+ inputSchema: {
60
+ type: 'object',
61
+ properties: {
62
+ ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
63
+ body: { type: 'string', description: 'Comment body.' },
64
+ },
65
+ required: ['ticket', 'body'],
66
+ },
67
+ },
68
+ {
69
+ name: 'ticket_transition',
70
+ 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.',
71
+ inputSchema: {
72
+ type: 'object',
73
+ properties: {
74
+ ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
75
+ target: { type: 'string', description: 'Target status/transition name. Omit to just list the tracker\'s current valid options.' },
76
+ confirm: { type: 'boolean', description: 'Must be true, alongside `target`, to actually execute the transition — a nudge and audit trail, not just a formality.' },
77
+ },
78
+ required: ['ticket'],
79
+ },
80
+ },
81
+ {
82
+ name: 'ticket_assign',
83
+ 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.',
84
+ inputSchema: {
85
+ type: 'object',
86
+ properties: {
87
+ ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
88
+ to: { type: 'string', description: 'Who to assign to — currently only "me" is accepted.' },
89
+ },
90
+ required: ['ticket', 'to'],
91
+ },
92
+ },
52
93
  ];
53
94
 
54
95
  function jsonRpcResult(id, result) {
@@ -112,14 +153,73 @@ async function callRecallSearch(args, { configDir, runRecallFn }) {
112
153
  return ok ? { content } : { isError: true, content };
113
154
  }
114
155
 
156
+ /**
157
+ * `ticket`/`body` become single opaque cmdArgs elements (`--body=${body}`),
158
+ * same reasoning as buildNoteAddArgs above — a body containing literal
159
+ * `--confirm` or `--target=` text stays inert since parseFlag only matches
160
+ * a whole array element via startsWith, never scans inside one.
161
+ */
162
+ async function callTicketComment(args, { configDir, runTicketCommentFn }) {
163
+ if (!args.ticket) {
164
+ return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
165
+ }
166
+ if (!args.body) {
167
+ return { isError: true, content: [{ type: 'text', text: 'Missing required argument: body' }] };
168
+ }
169
+ const capture = capturingStream();
170
+ const { ok } = await runTicketCommentFn([args.ticket, `--body=${args.body}`], { configDir, stream: capture });
171
+ const content = [{ type: 'text', text: capture.text }];
172
+ return ok ? { content } : { isError: true, content };
173
+ }
174
+
175
+ /**
176
+ * No `target` → discovery only, dispatched to the read-only list function —
177
+ * never touches the mutating path. `target` present → dispatched to the
178
+ * executing function, which itself still refuses without `confirm: true`
179
+ * (the MCP layer doesn't pre-empt that check, so the same refusal message
180
+ * a CLI user sees is what a calling AI harness sees too).
181
+ */
182
+ async function callTicketTransition(args, { configDir, runTicketTransitionListFn, runTicketTransitionFn }) {
183
+ if (!args.ticket) {
184
+ return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
185
+ }
186
+ const capture = capturingStream();
187
+ if (!args.target) {
188
+ const { ok } = await runTicketTransitionListFn([args.ticket], { configDir, stream: capture });
189
+ const content = [{ type: 'text', text: capture.text }];
190
+ return ok ? { content } : { isError: true, content };
191
+ }
192
+ const cmdArgs = [args.ticket, `--target=${args.target}`];
193
+ if (args.confirm === true) cmdArgs.push('--confirm');
194
+ const { ok } = await runTicketTransitionFn(cmdArgs, { configDir, stream: capture });
195
+ const content = [{ type: 'text', text: capture.text }];
196
+ return ok ? { content } : { isError: true, content };
197
+ }
198
+
199
+ async function callTicketAssign(args, { configDir, runTicketAssignFn }) {
200
+ if (!args.ticket) {
201
+ return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
202
+ }
203
+ if (!args.to) {
204
+ return { isError: true, content: [{ type: 'text', text: 'Missing required argument: to' }] };
205
+ }
206
+ const capture = capturingStream();
207
+ const { ok } = await runTicketAssignFn([args.ticket, `--to=${args.to}`], { configDir, stream: capture });
208
+ const content = [{ type: 'text', text: capture.text }];
209
+ return ok ? { content } : { isError: true, content };
210
+ }
211
+
115
212
  async function handleToolsCall(params, deps) {
116
213
  const { name, arguments: args = {} } = params ?? {};
117
214
  if (name === 'recall_add') return callRecallAdd(args, deps);
118
215
  if (name === 'recall_search') return callRecallSearch(args, deps);
216
+ if (name === 'ticket_comment') return callTicketComment(args, deps);
217
+ if (name === 'ticket_transition') return callTicketTransition(args, deps);
218
+ if (name === 'ticket_assign') return callTicketAssign(args, deps);
119
219
  return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
120
220
  }
121
221
 
122
- async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn }) {
222
+ async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn }) {
123
223
  let msg;
124
224
  try {
125
225
  msg = JSON.parse(raw);
@@ -149,7 +249,7 @@ async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn }) {
149
249
 
150
250
  if (method === 'tools/call') {
151
251
  try {
152
- const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn });
252
+ const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn });
153
253
  return jsonRpcResult(id, result);
154
254
  } catch (err) {
155
255
  return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
@@ -172,6 +272,10 @@ export function runMcpServer({
172
272
  stdout = process.stdout,
173
273
  runNoteAddFn = runNoteAdd,
174
274
  runRecallFn = runRecall,
275
+ runTicketCommentFn = runTicketComment,
276
+ runTicketTransitionListFn = runTicketTransitionList,
277
+ runTicketTransitionFn = runTicketTransition,
278
+ runTicketAssignFn = runTicketAssign,
175
279
  } = {}) {
176
280
  // A client can disconnect mid-write (EPIPE) at any time on a long-lived
177
281
  // process — an unhandled 'error' event on either stream would otherwise
@@ -191,7 +295,7 @@ export function runMcpServer({
191
295
  // never resolving (a dropped rejection isn't a resolution) — the
192
296
  // server would hang on shutdown instead of exiting.
193
297
  queue = queue.then(async () => {
194
- const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn });
298
+ const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn });
195
299
  if (response) stdout.write(response);
196
300
  }).catch(() => {});
197
301
  });
@@ -19,7 +19,7 @@ export function detectTrackerType(baseUrl) {
19
19
  * Instantiates the correct tracker adapter for a resolved connection.
20
20
  * @param {{ baseUrl: string, auth?: string, email?: string, apiToken?: string, pat?: string }} conn
21
21
  * @param {{ fetcher?: Function }} [opts]
22
- * @returns {{ type: string, fetchTicket: Function, fetchCurrentUser: Function, searchTickets: Function, fetchStatuses: Function }}
22
+ * @returns {{ type: string, fetchTicket: Function, fetchCurrentUser: Function, searchTickets: Function, fetchStatuses: Function, addComment: Function, getTransitions: Function, transition: Function, assignToSelf: Function }}
23
23
  */
24
24
  export function resolveAdapter(conn, opts = {}) {
25
25
  const type = detectTrackerType(conn?.baseUrl);
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Local debounce guard against accidentally firing the same ticket write
3
+ * twice in quick succession (a flaky agent retry loop, a doubled CLI
4
+ * invocation, a human hitting enter twice). This is not a security boundary
5
+ * and not a notification-style cooldown — just enough of a window to catch a
6
+ * true double-fire without blocking a deliberate follow-up action moments
7
+ * later.
8
+ *
9
+ * Deliberately a separate file from ticket-action-log.mjs (the append-only
10
+ * audit trail): this file is small and read on every write attempt, so it
11
+ * must stay O(1) to check. A shared file would force scanning full history
12
+ * per check, or risk one read-modify-write clobbering the other's data.
13
+ * Same read-modify-write-no-lock tradeoff already accepted by
14
+ * recall-queue.mjs for the same reason — low-frequency, single-user CLI.
15
+ */
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
20
+ import { writeFileAtomically } from './recall-vault.mjs';
21
+
22
+ const COOLDOWN_FILE = 'ticket-action-cooldown.json';
23
+
24
+ /** Default debounce window: long enough to catch a true double-fire, short enough to never block a deliberate follow-up action. */
25
+ export const DEFAULT_COOLDOWN_MS = 10_000;
26
+
27
+ function cooldownPath(configDir) {
28
+ return path.join(configDir, COOLDOWN_FILE);
29
+ }
30
+
31
+ function readCooldowns(configDir) {
32
+ try {
33
+ const parsed = JSON.parse(fs.readFileSync(cooldownPath(configDir), 'utf8'));
34
+ return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {};
35
+ } catch {
36
+ return {};
37
+ }
38
+ }
39
+
40
+ function cooldownKey(ticketKey, action) {
41
+ return `${ticketKey}:${action}`;
42
+ }
43
+
44
+ /**
45
+ * @param {string} ticketKey
46
+ * @param {string} action
47
+ * @param {{ configDir?: string, cooldownMs?: number, now?: () => number }} [opts]
48
+ * @returns {{ active: boolean, remainingMs: number }}
49
+ */
50
+ export function checkCooldown(ticketKey, action, {
51
+ configDir = DEFAULT_CONFIG_DIR,
52
+ cooldownMs = DEFAULT_COOLDOWN_MS,
53
+ now = () => Date.now(),
54
+ } = {}) {
55
+ const lastAt = readCooldowns(configDir)[cooldownKey(ticketKey, action)];
56
+ if (!lastAt) return { active: false, remainingMs: 0 };
57
+
58
+ const elapsed = now() - new Date(lastAt).getTime();
59
+ const remainingMs = cooldownMs - elapsed;
60
+ return remainingMs > 0 ? { active: true, remainingMs } : { active: false, remainingMs: 0 };
61
+ }
62
+
63
+ /**
64
+ * @param {string} ticketKey
65
+ * @param {string} action
66
+ * @param {{ configDir?: string, now?: () => number }} [opts]
67
+ */
68
+ export function recordAction(ticketKey, action, { configDir = DEFAULT_CONFIG_DIR, now = () => Date.now() } = {}) {
69
+ const cooldowns = readCooldowns(configDir);
70
+ cooldowns[cooldownKey(ticketKey, action)] = new Date(now()).toISOString();
71
+ writeFileAtomically(cooldownPath(configDir), JSON.stringify(cooldowns));
72
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Append-only forensic audit trail for ticket writes (comment/transition).
3
+ * Every entry is JSON-serialized on its own line — comment bodies are
4
+ * attacker/AI-controlled free text, and a raw newline embedded in one could
5
+ * otherwise forge a fake extra log line (same bug class already fixed once
6
+ * in this codebase for brief section filenames). JSON.stringify escapes
7
+ * embedded newlines as \n within the string, so one JSON.parse per line
8
+ * always recovers exactly one real entry.
9
+ *
10
+ * Deliberately a separate file from ticket-action-cooldown.mjs — this one
11
+ * only ever grows and is never read on the hot path of a write attempt, so
12
+ * its O(n) full-file nature doesn't matter to normal command latency.
13
+ *
14
+ * ticketKey is validated by the caller (ticket-command.mjs, via
15
+ * TICKET_KEY_PATTERN) before it ever reaches here — this module re-validates
16
+ * defensively since a log line is a place a malformed key could otherwise do
17
+ * real damage (path traversal has no surface here since the log file path
18
+ * never incorporates ticketKey, but a stray literal newline inside an
19
+ * unvalidated key would reintroduce exactly the forgeable-line risk this
20
+ * file exists to prevent).
21
+ */
22
+
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
26
+ import { TICKET_KEY_PATTERN } from './cli.mjs';
27
+
28
+ const LOG_FILE = 'ticket-action-log.jsonl';
29
+
30
+ function logPath(configDir) {
31
+ return path.join(configDir, LOG_FILE);
32
+ }
33
+
34
+ /**
35
+ * @param {{ ticketKey: string, action: 'comment'|'transition', actor: string, tracker: string, detail?: object }} entry
36
+ * @param {{ configDir?: string, now?: () => Date }} [opts]
37
+ */
38
+ export function logAction({ ticketKey, action, actor, tracker, detail = {} }, {
39
+ configDir = DEFAULT_CONFIG_DIR,
40
+ now = () => new Date(),
41
+ } = {}) {
42
+ if (!TICKET_KEY_PATTERN.test(ticketKey)) {
43
+ throw new Error(`Refusing to log malformed ticket key: ${JSON.stringify(ticketKey)}`);
44
+ }
45
+ const line = JSON.stringify({ ticketKey, action, actor, tracker, detail, at: now().toISOString() });
46
+ fs.appendFileSync(logPath(configDir), line + '\n', 'utf8');
47
+ }
48
+
49
+ /**
50
+ * @param {string} [configDir]
51
+ * @returns {object[]} parsed entries, oldest first — a corrupt individual
52
+ * line is skipped rather than failing the whole read, since this is an
53
+ * append-only historical record and one bad line must not hide the rest.
54
+ */
55
+ export function readActionLog(configDir = DEFAULT_CONFIG_DIR) {
56
+ let raw;
57
+ try {
58
+ raw = fs.readFileSync(logPath(configDir), 'utf8');
59
+ } catch {
60
+ return [];
61
+ }
62
+ const entries = [];
63
+ for (const line of raw.split('\n')) {
64
+ if (!line.trim()) continue;
65
+ try {
66
+ entries.push(JSON.parse(line));
67
+ } catch {
68
+ // Skip a corrupt line — never let one bad append hide the rest of the trail.
69
+ }
70
+ }
71
+ return entries;
72
+ }