ticketlens 0.27.0 → 0.30.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.
@@ -53,12 +53,14 @@ export function printHelp({ stream = process.stdout } = {}) {
53
53
  ` ${s.brand('ticketlens')} recall ${s.dim('<query|TICKET-KEY>')} Search your saved Recall notes ${s.dim('[Pro]')}`,
54
54
  ` ${s.brand('ticketlens')} recall sync Retry any notes stuck in the local queue ${s.dim('[Team+]')}`,
55
55
  ` ${s.brand('ticketlens')} recall settings Show effective retry-queue settings, fetched live ${s.dim('[Team+]')}`,
56
- ` ${s.brand('ticketlens')} mcp Start the MCP stdio server for Recall ${s.dim('[Pro]')}`,
56
+ ` ${s.brand('ticketlens')} mcp Start the MCP stdio server (Recall + ticket writes) ${s.dim('[Pro]')}`,
57
57
  ` ${s.brand('ticketlens')} comment ${s.dim('<TICKET-KEY> --body=...')} Post a comment to the tracker ${s.dim('[Pro]')}`,
58
58
  ` ${s.brand('ticketlens')} transition ${s.dim('<TICKET-KEY> [--target=... --confirm]')} Move ticket status ${s.dim('[Pro]')}`,
59
59
  ` ${s.brand('ticketlens')} assign ${s.dim('<TICKET-KEY> --to=me')} Assign a ticket to yourself ${s.dim('[Pro]')}`,
60
60
  ` ${s.brand('ticketlens')} duplicates ${s.dim('<TICKET-KEY> [--threshold=N]')} Find likely duplicate tickets ${s.dim('[Pro]')}`,
61
61
  ` ${s.brand('ticketlens')} link ${s.dim('<SOURCE> <TARGET> [--type=... --confirm]')} Link two tickets ${s.dim('[Pro]')}`,
62
+ ` ${s.brand('ticketlens')} update ${s.dim('<TICKET-KEY> [--title=... --description=... --priority=... --add-labels=... --remove-labels=...]')} Update fields ${s.dim('[Pro]')}`,
63
+ ` ${s.brand('ticketlens')} create ${s.dim('--project=... [--type=...] --summary=... [--description=...]')} Create a new ticket ${s.dim('[Pro]')}`,
62
64
  '',
63
65
  ` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
64
66
  ` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
@@ -179,6 +181,7 @@ export function printFetchHelp({ stream = process.stdout } = {}) {
179
181
  ` ${s.brand('--cloud')} Route AI request through TicketLens API ${s.dim('[Pro]')}`,
180
182
  ` ${s.brand('--provider')}=${s.dim('NAME')} Force AI provider ${s.dim('(anthropic|openai|groq)')}`,
181
183
  ` ${s.brand('--template')}=${s.dim('SLUG')} Apply a brief template ${s.dim('(full|quick|code-review, or custom [Team])')}`,
184
+ ` ${s.brand('--budget')}=${s.dim('N')} Trim brief to fit a token budget ${s.dim('[Pro]')}`,
182
185
  ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
183
186
  '',
184
187
  ` ${s.bold('EXAMPLES')}`,
@@ -194,6 +197,32 @@ export function printFetchHelp({ stream = process.stdout } = {}) {
194
197
  stream.write(lines.join('\n') + '\n');
195
198
  }
196
199
 
200
+ export function printHistoryHelp({ stream = process.stdout } = {}) {
201
+ const s = createStyler({ isTTY: stream.isTTY });
202
+ const lines = [
203
+ '',
204
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('history')} ${s.dim('TICKET-KEY')} ${s.dim('[Pro]')}`,
205
+ '',
206
+ ` Show this ticket's urgency timeline from your local triage history — ${s.dim('[Pro]')}`,
207
+ ` every prior triage scan that surfaced it, with the urgency level and reason`,
208
+ ` computed at that point in time. Read-only, entirely local — no network call.`,
209
+ '',
210
+ ` ${s.bold('ARGUMENTS')}`,
211
+ '',
212
+ ` ${s.brand('TICKET-KEY')} The ticket to show history for ${s.dim('(required)')}`,
213
+ '',
214
+ ` ${s.bold('OPTIONS')}`,
215
+ '',
216
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
217
+ '',
218
+ ` ${s.bold('EXAMPLES')}`,
219
+ '',
220
+ ` ${s.dim('$')} ticketlens history PROJ-123`,
221
+ '',
222
+ ];
223
+ stream.write(lines.join('\n') + '\n');
224
+ }
225
+
197
226
  const ANSI_RE_HELP = /\x1b\[[0-9;]*m/g;
198
227
  function padRightVis(str, len) {
199
228
  const vis = str.replace(ANSI_RE_HELP, '').length;
@@ -599,13 +628,16 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
599
628
  '',
600
629
  ` Start an MCP (Model Context Protocol) stdio server exposing Recall and`,
601
630
  ` ticket writes as native tools — ${s.cyan('recall_add')}, ${s.cyan('recall_search')}, ${s.cyan('ticket_comment')},`,
602
- ` ${s.cyan('ticket_transition')}, ${s.cyan('ticket_assign')}, ${s.cyan('ticket_duplicates')}, ${s.cyan('ticket_link')} — for any MCP-compatible`,
603
- ` AI harness, not just Claude Code. Thin adapter over the same code as`,
604
- ` ${s.cyan('note add')}/${s.cyan('recall')}/${s.cyan('comment')}/${s.cyan('transition')}/${s.cyan('assign')}/${s.cyan('duplicates')}/${s.cyan('link')} above: same Pro gate,`,
605
- ` same local vault/tracker writes, same team sync. ${s.cyan('ticket_transition')} is destructive`,
606
- ` when called with \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')} is currently self-assign`,
607
- ` only; ${s.cyan('ticket_duplicates')} is read-only; ${s.cyan('ticket_link')} on GitHub closes the source`,
608
- ` issue as a duplicate — different semantics than Jira/Linear's relationship-only add.`,
631
+ ` ${s.cyan('ticket_transition')}, ${s.cyan('ticket_assign')}, ${s.cyan('ticket_duplicates')}, ${s.cyan('ticket_link')}, ${s.cyan('ticket_update')}, ${s.cyan('ticket_create')} — for any`,
632
+ ` MCP-compatible AI harness, not just Claude Code. Thin adapter over the same`,
633
+ ` code as ${s.cyan('note add')}/${s.cyan('recall')}/${s.cyan('comment')}/${s.cyan('transition')}/${s.cyan('assign')}/${s.cyan('duplicates')}/${s.cyan('link')}/${s.cyan('update')}/${s.cyan('create')} above:`,
634
+ ` same Pro gate, same local vault/tracker writes, same team sync. ${s.cyan('ticket_transition')} is`,
635
+ ` destructive when called with \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')} is currently`,
636
+ ` self-assign only; ${s.cyan('ticket_duplicates')} is read-only; ${s.cyan('ticket_link')} on GitHub closes the`,
637
+ ` source issue as a duplicate — different semantics than Jira/Linear's relationship-only`,
638
+ ` add; ${s.cyan('ticket_update')} has no priority field on GitHub and can partially succeed;`,
639
+ ` ${s.cyan('ticket_create')} has no ticket key to target — --profile/the default profile picks the`,
640
+ ` tracker, and it fabricates a real item, the highest blast radius of this family.`,
609
641
  ` Long-running — exits when the client closes stdin.`,
610
642
  '',
611
643
  ` ${s.bold('OPTIONS')}`,
@@ -765,6 +797,81 @@ export function printLinkHelp({ stream = process.stdout } = {}) {
765
797
  stream.write(lines.join('\n') + '\n');
766
798
  }
767
799
 
800
+ export function printUpdateHelp({ stream = process.stdout } = {}) {
801
+ const s = createStyler({ isTTY: stream.isTTY });
802
+ const lines = [
803
+ '',
804
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('update')} ${s.dim('TICKET-KEY [--title=...] [--description=...] [--add-labels=a,b] [--remove-labels=c] [--priority=...]')} ${s.dim('[Pro]')}`,
805
+ '',
806
+ ` Update a narrow, named field set on a ticket (Jira/GitHub/Linear). ${s.dim('[Pro]')}`,
807
+ ` At least one field is required. Labels are add/remove, never a wholesale`,
808
+ ` replace — an unnamed label is left alone, not dropped.`,
809
+ '',
810
+ ` ${s.bold('GitHub has no priority field')}: passing ${s.brand('--priority')} against a GitHub-tracked`,
811
+ ` ticket is refused up front rather than silently ignored.`,
812
+ '',
813
+ ` A write can partially succeed (e.g. title updates but a label doesn't`,
814
+ ` resolve) — the result always reports exactly what landed.`,
815
+ '',
816
+ ` ${s.bold('OPTIONS')}`,
817
+ '',
818
+ ` ${s.brand('--title')}=${s.dim('TEXT')} New title/summary`,
819
+ ` ${s.brand('--description')}=${s.dim('TEXT')} New description`,
820
+ ` ${s.brand('--add-labels')}=${s.dim('a,b')} Labels to add ${s.dim('(comma-separated)')}`,
821
+ ` ${s.brand('--remove-labels')}=${s.dim('c')} Labels to remove ${s.dim('(comma-separated)')}`,
822
+ ` ${s.brand('--priority')}=${s.dim('NAME')} New priority ${s.dim('(not supported on GitHub)')}`,
823
+ ` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
824
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
825
+ '',
826
+ ` ${s.bold('EXAMPLES')}`,
827
+ '',
828
+ ` ${s.dim('$')} ticketlens update PROD-123 --title="Fix login on mobile"`,
829
+ ` ${s.dim('$')} ticketlens update PROD-123 --add-labels=urgent,backend --remove-labels=stale`,
830
+ ` ${s.dim('$')} ticketlens update PROD-123 --priority="High"`,
831
+ '',
832
+ ];
833
+ stream.write(lines.join('\n') + '\n');
834
+ }
835
+
836
+ export function printCreateHelp({ stream = process.stdout } = {}) {
837
+ const s = createStyler({ isTTY: stream.isTTY });
838
+ const lines = [
839
+ '',
840
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('create')} ${s.dim('--project=... [--type=...] --summary=... [--description=...]')} ${s.dim('[Pro]')}`,
841
+ '',
842
+ ` Create a new ticket in a tracker (Jira/GitHub/Linear) with a fixed minimal`,
843
+ ` field set — no arbitrary custom fields. ${s.dim('[Pro]')} Unlike every other ticket-write`,
844
+ ` command, there is no existing ticket to operate on — the target tracker is`,
845
+ ` picked by ${s.brand('--profile')} (or your default profile), not a ticket key.`,
846
+ '',
847
+ ` ${s.bold('--project is tracker-specific')}: the Jira project key or Linear team key.`,
848
+ ` Required for Jira/Linear. GitHub ignores it — its target repo is already`,
849
+ ` fixed by the profile.`,
850
+ '',
851
+ ` ${s.bold('--type is Jira-only')}: the issue type, e.g. "Task" or "Bug". Required for`,
852
+ ` Jira. GitHub/Linear have no equivalent concept and ignore it if given.`,
853
+ '',
854
+ ` This is the highest-blast-radius command in the ticket-write family — a bad`,
855
+ ` ${s.brand('--project')}/${s.brand('--type')} fabricates a real, hard-to-walk-back item in a live tracker.`,
856
+ '',
857
+ ` ${s.bold('OPTIONS')}`,
858
+ '',
859
+ ` ${s.brand('--project')}=${s.dim('KEY')} Project key (Jira) or team key (Linear) ${s.dim('(required, except GitHub)')}`,
860
+ ` ${s.brand('--type')}=${s.dim('NAME')} Issue type ${s.dim('(Jira only, required there)')}`,
861
+ ` ${s.brand('--summary')}=${s.dim('TEXT')} Ticket title/summary ${s.dim('(required)')}`,
862
+ ` ${s.brand('--description')}=${s.dim('TEXT')} Ticket description`,
863
+ ` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
864
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
865
+ '',
866
+ ` ${s.bold('EXAMPLES')}`,
867
+ '',
868
+ ` ${s.dim('$')} ticketlens create --project=PROD --type="Task" --summary="Fix login on mobile"`,
869
+ ` ${s.dim('$')} ticketlens create --project=ENG --summary="New Linear issue" --profile=linear-team`,
870
+ '',
871
+ ];
872
+ stream.write(lines.join('\n') + '\n');
873
+ }
874
+
768
875
  export function printSwitchHelp({ stream = process.stdout } = {}) {
769
876
  const s = createStyler({ isTTY: stream.isTTY });
770
877
  const lines = [
@@ -587,6 +587,134 @@ export async function assignIssue(ticketKey, assignee, opts = {}) {
587
587
  }
588
588
  }
589
589
 
590
+ /**
591
+ * Updates a narrow, named field set on an issue. `fields` (summary,
592
+ * description, priority) uses plain SET semantics — the same shape Jira's
593
+ * own GET returns, same convention as assignIssue/postIssueLink. Labels use
594
+ * a separate verb-based `update.labels` array (`{add}`/`{remove}`) — Jira's
595
+ * REST API has no plain-replace shape for multi-value fields, only ADD/SET/
596
+ * REMOVE operations, a genuinely different mechanic from fields. Both keys
597
+ * can coexist in one request as long as a given field only appears in one
598
+ * of them — never both — which callers naturally satisfy since labels only
599
+ * ever go through `update`.
600
+ */
601
+ export async function updateIssue(ticketKey, { summary, description, priority, addLabels, removeLabels } = {}, opts = {}) {
602
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
603
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
604
+ const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
605
+ const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}`;
606
+
607
+ const fields = {};
608
+ if (summary !== undefined) fields.summary = summary;
609
+ if (description !== undefined) fields.description = apiVersion === 3 ? textToAdf(description) : description;
610
+ if (priority !== undefined) fields.priority = { name: priority };
611
+
612
+ const labelOps = [
613
+ ...(addLabels ?? []).map(label => ({ add: label })),
614
+ ...(removeLabels ?? []).map(label => ({ remove: label })),
615
+ ];
616
+
617
+ const body = {};
618
+ if (Object.keys(fields).length > 0) body.fields = fields;
619
+ if (labelOps.length > 0) body.update = { labels: labelOps };
620
+
621
+ const fetchOpts = {
622
+ method: 'PUT',
623
+ headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' },
624
+ body: JSON.stringify(body),
625
+ };
626
+ if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
627
+
628
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
629
+ if (!response.ok) {
630
+ let details;
631
+ try { details = await response.json(); } catch { /* body not JSON — fall through with no details */ }
632
+ const err = new Error(`Jira API error ${response.status} updating ${ticketKey}`);
633
+ err.status = response.status;
634
+ err.details = details;
635
+ throw err;
636
+ }
637
+ }
638
+
639
+ /**
640
+ * Discovers the issue types actually configured for a project — used only
641
+ * to enrich a ticket_create failure message with real, current options,
642
+ * never as a client-side pre-validation step (issuetype validity is still
643
+ * surfaced via the tracker's own 400, same design choice as createIssue
644
+ * itself). A single, unpaginated call: real-world Jira projects rarely
645
+ * exceed a handful to a few dozen issue types (well under the endpoint's
646
+ * own default maxResults=50), and a truncated list here would only ever
647
+ * under-inform an error message, never break the actual write path — full
648
+ * pagination (as fetchProjects needs, since an org's total project count
649
+ * has no such natural ceiling) would be disproportionate complexity for
650
+ * an enrichment-only feature.
651
+ *
652
+ * v2 (Server/DC) and v3 (Cloud) return genuinely different response
653
+ * envelopes for this same resource — confirmed directly against
654
+ * Atlassian's own Cloud OpenAPI spec and a live-downloaded Server/DC WADL,
655
+ * not assumed just because the URL pattern matches: v3 nests results under
656
+ * `issueTypes`, v2 under `values` (same distinction fetchProjects already
657
+ * makes for its own project-listing endpoint, for the same underlying
658
+ * reason — the two API generations were never response-shape-compatible).
659
+ */
660
+ export async function fetchIssueTypes(projectKey, opts = {}) {
661
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
662
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
663
+ const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
664
+ const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
665
+
666
+ const url = `${baseUrl}/rest/api/${apiVersion}/issue/createmeta/${encodeURIComponent(projectKey)}/issuetypes`;
667
+ const fetchOpts = { headers };
668
+ if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
669
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
670
+
671
+ if (!response.ok) {
672
+ const err = new Error(`Jira API error ${response.status} fetching issue types for ${projectKey}`);
673
+ err.status = response.status;
674
+ throw err;
675
+ }
676
+
677
+ const raw = await response.json();
678
+ const values = apiVersion >= 3 ? (raw.issueTypes ?? []) : (raw.values ?? []);
679
+ return values.map(t => ({ id: t.id, name: t.name }));
680
+ }
681
+
682
+ /**
683
+ * Creates a new issue. `project`/`type` are passed straight through as
684
+ * Jira's own {key}/{name} references — issue types are project-configurable,
685
+ * so validity is never pre-checked here; an invalid one surfaces Jira's own
686
+ * 400 with details, same design choice already made for updateIssue's
687
+ * priority field. No separate discovery call, no client-side field list.
688
+ */
689
+ export async function createIssue({ project, type, summary, description } = {}, opts = {}) {
690
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
691
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
692
+ const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
693
+ const url = `${baseUrl}/rest/api/${apiVersion}/issue`;
694
+
695
+ const fields = { project: { key: project }, issuetype: { name: type }, summary };
696
+ if (description !== undefined) fields.description = apiVersion === 3 ? textToAdf(description) : description;
697
+
698
+ const fetchOpts = {
699
+ method: 'POST',
700
+ headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' },
701
+ body: JSON.stringify({ fields }),
702
+ };
703
+ if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
704
+
705
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
706
+ if (!response.ok) {
707
+ let details;
708
+ try { details = await response.json(); } catch { /* body not JSON — fall through with no details */ }
709
+ const err = new Error(`Jira API error ${response.status} creating an issue in ${project}`);
710
+ err.status = response.status;
711
+ err.details = details;
712
+ throw err;
713
+ }
714
+ const raw = await response.json();
715
+ return { key: raw.key, id: raw.id, url: raw.self ?? null };
716
+ }
717
+
590
718
  export async function fetchTicket(ticketKey, opts = {}) {
591
719
  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;
592
720
  validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
@@ -23,7 +23,7 @@ import readline from 'node:readline';
23
23
  import { DEFAULT_CONFIG_DIR, getVersion } from './config.mjs';
24
24
  import { runNoteAdd } from './note-command.mjs';
25
25
  import { runRecall } from './recall-command.mjs';
26
- import { runTicketComment, runTicketTransitionList, runTicketTransition, runTicketAssign, runTicketDuplicates, runTicketLinkList, runTicketLink } from './ticket-command.mjs';
26
+ import { runTicketComment, runTicketTransitionList, runTicketTransition, runTicketAssign, runTicketDuplicates, runTicketLinkList, runTicketLink, runTicketUpdate, runTicketCreate } from './ticket-command.mjs';
27
27
 
28
28
  const PROTOCOL_VERSION = '2025-11-25';
29
29
 
@@ -116,6 +116,36 @@ const TOOLS = [
116
116
  required: ['ticket', 'target'],
117
117
  },
118
118
  },
119
+ {
120
+ name: 'ticket_update',
121
+ description: 'Update a narrow, named field set on a ticket in its tracker (Jira/GitHub/Linear) — title, description, labels, priority. 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.',
122
+ inputSchema: {
123
+ type: 'object',
124
+ properties: {
125
+ ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
126
+ title: { type: 'string', description: 'New title/summary. Omit to leave unchanged.' },
127
+ description: { type: 'string', description: 'New description. Omit to leave unchanged.' },
128
+ addLabels: { type: 'array', items: { type: 'string' }, description: 'Labels to add. Existing labels not named here are left alone.' },
129
+ removeLabels: { type: 'array', items: { type: 'string' }, description: 'Labels to remove.' },
130
+ priority: { type: 'string', description: 'New priority name, e.g. "High". Not supported on GitHub.' },
131
+ },
132
+ required: ['ticket'],
133
+ },
134
+ },
135
+ {
136
+ name: 'ticket_create',
137
+ 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.',
138
+ inputSchema: {
139
+ type: 'object',
140
+ properties: {
141
+ project: { type: 'string', description: 'Jira project key or Linear team key. Required for Jira/Linear; ignored on GitHub.' },
142
+ type: { type: 'string', description: 'Jira issue type, e.g. "Task" or "Bug". Required for Jira only; ignored on GitHub/Linear.' },
143
+ summary: { type: 'string', description: 'Ticket title/summary.' },
144
+ description: { type: 'string', description: 'Ticket description. Omit for none.' },
145
+ },
146
+ required: ['summary'],
147
+ },
148
+ },
119
149
  ];
120
150
 
121
151
  function jsonRpcResult(id, result) {
@@ -273,6 +303,66 @@ async function callTicketLink(args, { configDir, runTicketLinkListFn, runTicketL
273
303
  return ok ? { content } : { isError: true, content };
274
304
  }
275
305
 
306
+ /**
307
+ * `title`/`description`/`priority` each become one opaque cmdArgs element,
308
+ * same reasoning as buildNoteAddArgs/callTicketComment above. `addLabels`/
309
+ * `removeLabels` arrive as arrays per the MCP schema and are comma-joined
310
+ * into a single element (same convention buildNoteAddArgs already uses for
311
+ * `tags`) — parsed back apart by ticket-command.mjs's existing split(',')
312
+ * handling, never by re-splitting a string this function builds itself.
313
+ */
314
+ function buildTicketUpdateArgs(args) {
315
+ const cmdArgs = [args.ticket];
316
+ if (args.title !== undefined) cmdArgs.push(`--title=${args.title}`);
317
+ if (args.description !== undefined) cmdArgs.push(`--description=${args.description}`);
318
+ if (args.priority !== undefined) cmdArgs.push(`--priority=${args.priority}`);
319
+ if (Array.isArray(args.addLabels) && args.addLabels.length > 0) cmdArgs.push(`--add-labels=${args.addLabels.join(',')}`);
320
+ if (Array.isArray(args.removeLabels) && args.removeLabels.length > 0) cmdArgs.push(`--remove-labels=${args.removeLabels.join(',')}`);
321
+ return cmdArgs;
322
+ }
323
+
324
+ /**
325
+ * No list-then-act split, unlike ticket_transition/ticket_link — update has
326
+ * no discovery step, so there is exactly one dispatch path. Whether at
327
+ * least one field was actually given is runTicketUpdateFn's own concern
328
+ * (same "don't pre-empt the underlying command" principle as ticket_link's
329
+ * --confirm check).
330
+ */
331
+ async function callTicketUpdate(args, { configDir, runTicketUpdateFn }) {
332
+ if (!args.ticket) {
333
+ return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
334
+ }
335
+ const capture = capturingStream();
336
+ const { ok } = await runTicketUpdateFn(buildTicketUpdateArgs(args), { configDir, stream: capture });
337
+ const content = [{ type: 'text', text: capture.text }];
338
+ return ok ? { content } : { isError: true, content };
339
+ }
340
+
341
+ /**
342
+ * `project`/`type`/`description` each become one opaque cmdArgs element,
343
+ * same reasoning as buildNoteAddArgs/callTicketComment above. Unlike every
344
+ * other ticket-write tool, there is no `ticket` argument — creation has no
345
+ * existing ticket to target.
346
+ */
347
+ function buildTicketCreateArgs(args) {
348
+ const cmdArgs = [];
349
+ if (args.project !== undefined) cmdArgs.push(`--project=${args.project}`);
350
+ if (args.type !== undefined) cmdArgs.push(`--type=${args.type}`);
351
+ cmdArgs.push(`--summary=${args.summary}`);
352
+ if (args.description !== undefined) cmdArgs.push(`--description=${args.description}`);
353
+ return cmdArgs;
354
+ }
355
+
356
+ async function callTicketCreate(args, { configDir, runTicketCreateFn }) {
357
+ if (!args.summary) {
358
+ return { isError: true, content: [{ type: 'text', text: 'Missing required argument: summary' }] };
359
+ }
360
+ const capture = capturingStream();
361
+ const { ok } = await runTicketCreateFn(buildTicketCreateArgs(args), { configDir, stream: capture });
362
+ const content = [{ type: 'text', text: capture.text }];
363
+ return ok ? { content } : { isError: true, content };
364
+ }
365
+
276
366
  async function handleToolsCall(params, deps) {
277
367
  const { name, arguments: args = {} } = params ?? {};
278
368
  if (name === 'recall_add') return callRecallAdd(args, deps);
@@ -282,10 +372,12 @@ async function handleToolsCall(params, deps) {
282
372
  if (name === 'ticket_assign') return callTicketAssign(args, deps);
283
373
  if (name === 'ticket_duplicates') return callTicketDuplicates(args, deps);
284
374
  if (name === 'ticket_link') return callTicketLink(args, deps);
375
+ if (name === 'ticket_update') return callTicketUpdate(args, deps);
376
+ if (name === 'ticket_create') return callTicketCreate(args, deps);
285
377
  return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
286
378
  }
287
379
 
288
- async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn }) {
380
+ async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn }) {
289
381
  let msg;
290
382
  try {
291
383
  msg = JSON.parse(raw);
@@ -315,7 +407,7 @@ async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTic
315
407
 
316
408
  if (method === 'tools/call') {
317
409
  try {
318
- const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn });
410
+ const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
319
411
  return jsonRpcResult(id, result);
320
412
  } catch (err) {
321
413
  return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
@@ -345,6 +437,8 @@ export function runMcpServer({
345
437
  runTicketDuplicatesFn = runTicketDuplicates,
346
438
  runTicketLinkListFn = runTicketLinkList,
347
439
  runTicketLinkFn = runTicketLink,
440
+ runTicketUpdateFn = runTicketUpdate,
441
+ runTicketCreateFn = runTicketCreate,
348
442
  } = {}) {
349
443
  // A client can disconnect mid-write (EPIPE) at any time on a long-lived
350
444
  // process — an unhandled 'error' event on either stream would otherwise
@@ -364,7 +458,7 @@ export function runMcpServer({
364
458
  // never resolving (a dropped rejection isn't a resolution) — the
365
459
  // server would hang on shutdown instead of exiting.
366
460
  queue = queue.then(async () => {
367
- const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn });
461
+ const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
368
462
  if (response) stdout.write(response);
369
463
  }).catch(() => {});
370
464
  });