logwork-cli 0.0.0-stage → 1.1.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.
Files changed (96) hide show
  1. package/LICENSE +11 -0
  2. package/README.md +330 -2
  3. package/RELEASE.md +89 -0
  4. package/bin/logwork.mjs +104 -0
  5. package/commands/auth.mjs +227 -0
  6. package/commands/commits.mjs +82 -0
  7. package/commands/delete.mjs +69 -0
  8. package/commands/doctor.mjs +24 -0
  9. package/commands/edit.mjs +88 -0
  10. package/commands/history.mjs +62 -0
  11. package/commands/query.mjs +52 -0
  12. package/commands/quick.mjs +163 -0
  13. package/commands/reconcile.mjs +55 -0
  14. package/commands/release.mjs +77 -0
  15. package/commands/reminder.mjs +172 -0
  16. package/commands/setup.mjs +508 -0
  17. package/commands/skill.mjs +91 -0
  18. package/commands/tui.mjs +50 -0
  19. package/commands/update.mjs +161 -0
  20. package/docs/advanced.md +334 -0
  21. package/docs/mcp-setup.md +368 -0
  22. package/docs/operations-guide.md +587 -0
  23. package/docs/recipes.md +259 -0
  24. package/docs/security.md +151 -0
  25. package/examples/mcp/antigravity.mcp_config.json +8 -0
  26. package/examples/mcp/codex.config.toml +5 -0
  27. package/examples/mcp/copilot.mcp.json +9 -0
  28. package/lib/agent-skill.mjs +16 -0
  29. package/lib/api-auth.mjs +1259 -0
  30. package/lib/api.mjs +1422 -0
  31. package/lib/apply-ledger.mjs +227 -0
  32. package/lib/atomic-file.mjs +24 -0
  33. package/lib/auth-diagnostics.mjs +113 -0
  34. package/lib/auth-errors.mjs +22 -0
  35. package/lib/auth-redaction.mjs +78 -0
  36. package/lib/auth-terminal.mjs +97 -0
  37. package/lib/auth.mjs +323 -0
  38. package/lib/batch-parser.mjs +145 -0
  39. package/lib/batch-workflow.mjs +842 -0
  40. package/lib/commit-workflow.mjs +402 -0
  41. package/lib/config.mjs +191 -0
  42. package/lib/credential-store.mjs +130 -0
  43. package/lib/diagnostics.mjs +307 -0
  44. package/lib/entrypoint.mjs +14 -0
  45. package/lib/file-lock.mjs +44 -0
  46. package/lib/http.mjs +125 -0
  47. package/lib/jira-api.mjs +258 -0
  48. package/lib/jira-auth.mjs +203 -0
  49. package/lib/jira-workflow.mjs +523 -0
  50. package/lib/keychain.mjs +105 -0
  51. package/lib/legacy-migration.mjs +66 -0
  52. package/lib/list-navigation.mjs +19 -0
  53. package/lib/logwork-config.mjs +199 -0
  54. package/lib/logwork-f03-enrich.mjs +41 -0
  55. package/lib/macos.mjs +33 -0
  56. package/lib/manual-apply-state.mjs +35 -0
  57. package/lib/manual-commands.mjs +294 -0
  58. package/lib/manual-drafts.mjs +154 -0
  59. package/lib/manual-ink-app.mjs +1654 -0
  60. package/lib/manual-ink-auth.mjs +349 -0
  61. package/lib/manual-ink-ui.mjs +675 -0
  62. package/lib/manual-input.mjs +21 -0
  63. package/lib/manual-logwork-wizard.mjs +310 -0
  64. package/lib/manual-mcp.mjs +187 -0
  65. package/lib/manual-repl.mjs +1037 -0
  66. package/lib/manual-targets.mjs +78 -0
  67. package/lib/mcp-server.mjs +833 -0
  68. package/lib/os-notification.mjs +105 -0
  69. package/lib/package-info.mjs +17 -0
  70. package/lib/paths.mjs +68 -0
  71. package/lib/project-identity.mjs +66 -0
  72. package/lib/project-mapping-workflow.mjs +309 -0
  73. package/lib/project-resolver.mjs +373 -0
  74. package/lib/query-workflow.mjs +553 -0
  75. package/lib/reconciliation-workflow.mjs +446 -0
  76. package/lib/release-doctor.mjs +448 -0
  77. package/lib/reminder-config.mjs +71 -0
  78. package/lib/reminder-scheduler.mjs +301 -0
  79. package/lib/reminder-service.mjs +118 -0
  80. package/lib/reminder-state.mjs +38 -0
  81. package/lib/reminder-workflow.mjs +299 -0
  82. package/lib/ro-delete-args.mjs +38 -0
  83. package/lib/ro-delete-workflow.mjs +115 -0
  84. package/lib/ro-edit-args.mjs +142 -0
  85. package/lib/ro-edit-workflow.mjs +632 -0
  86. package/lib/ro-entry-reader.mjs +56 -0
  87. package/lib/ro-resubmit-workflow.mjs +134 -0
  88. package/lib/terminal-auth-prompts.mjs +120 -0
  89. package/lib/ui.mjs +133 -0
  90. package/lib/update-service.mjs +350 -0
  91. package/lib/util.mjs +66 -0
  92. package/lib/windows-credential-manager.mjs +260 -0
  93. package/lib/worklog-task-resolver.mjs +113 -0
  94. package/package.json +73 -4
  95. package/setup.sh +22 -0
  96. package/skills/logwork/SKILL.md +43 -0
@@ -0,0 +1,833 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { z } from 'zod';
4
+ import { ApiError } from './api.mjs';
5
+ import { queryApplyLedger } from './apply-ledger.mjs';
6
+ import { readLogworkSkill } from './agent-skill.mjs';
7
+ import { draftLogworkFromCommits } from './commit-workflow.mjs';
8
+ import {
9
+ applyLogworkBatch,
10
+ formatToolResponse,
11
+ listWorklogTasks,
12
+ previewLogworkBatch
13
+ } from './batch-workflow.mjs';
14
+ import { authRequiredPayload, isAuthRequiredError } from './auth-errors.mjs';
15
+ import { startAuthLoginTerminal } from './auth-terminal.mjs';
16
+ import { isMainModule } from './entrypoint.mjs';
17
+ import {
18
+ jiraAuthRequiredPayload,
19
+ getStoredJiraSession,
20
+ isJiraAuthRequiredError
21
+ } from './jira-auth.mjs';
22
+ import { getJiraIssue } from './jira-api.mjs';
23
+ import {
24
+ applyJiraWorklogBatch,
25
+ previewJiraWorklogBatch
26
+ } from './jira-workflow.mjs';
27
+ import {
28
+ listLogworkProjects,
29
+ upsertProjectMapping
30
+ } from './project-mapping-workflow.mjs';
31
+ import { readPackageVersion } from './package-info.mjs';
32
+ import { queryLogwork } from './query-workflow.mjs';
33
+ import { reconcileLogwork } from './reconciliation-workflow.mjs';
34
+ import {
35
+ applyRoLogworkEdit,
36
+ createRoEditPreviewFingerprint,
37
+ previewRoLogworkEdit
38
+ } from './ro-edit-workflow.mjs';
39
+ import {
40
+ applyRoLogworkResubmit,
41
+ previewRoLogworkResubmit
42
+ } from './ro-resubmit-workflow.mjs';
43
+ import {
44
+ applyRoLogworkDelete,
45
+ previewRoLogworkDelete
46
+ } from './ro-delete-workflow.mjs';
47
+ import {
48
+ disableLogworkReminder,
49
+ enableLogworkReminder,
50
+ getLogworkReminderStatus,
51
+ testLogworkReminder
52
+ } from './reminder-service.mjs';
53
+ import { checkForUpdates, installUpdate } from './update-service.mjs';
54
+
55
+ const PREVIEW_TTL_MS = 60 * 60 * 1000;
56
+ const MAX_PREVIEWS = 100;
57
+ const previews = new Map();
58
+ const jiraPreviews = new Map();
59
+ const roEditPreviews = new Map();
60
+ const roResubmitPreviews = new Map();
61
+ const roDeletePreviews = new Map();
62
+
63
+ export const SERVER_INSTRUCTIONS = `logwork logs work hours to Resource Optimiser (RO) and self-hosted Jira from the user's machine.
64
+
65
+ Safety rules:
66
+ - Writes are two calls to the same tool. Call it without confirm to get a preview (batchId or previewId plus a summary), show the summary to the user, wait for explicit approval, then call the same tool again with only that id and confirm: true. Never set confirm: true without approval.
67
+ - Never ask for or pass passwords, OTP codes, or tokens. If a tool returns authRequired, call login (target "ro" or "jira") and ask the user to finish login in their terminal.
68
+
69
+ Logwork text format (log_work and log_jira_work):
70
+ Monday, 01 Jun 2026
71
+ +2 Feature Development | Implement maintenance mode UI (SCB-213)
72
+ +1.5 Code Review & Refactoring | Review PR for system page (SCB-214)
73
+ - Day heading: "<Weekday>, <DD> <Mon> <YYYY>".
74
+ - Entry: "+<hours> <worklog task> | <description>". The worklog task must be an RO catalog task name (or "#<id>") from list_tasks; the description is free text written to RO and used as the Jira worklog comment. Always include a description.
75
+ - Lines without "|" are accepted only when the text is itself a catalog task name (no description is written), or when defaultWorklogTask is passed (the text then becomes the description).
76
+ - Jira needs exactly one issue key such as SCB-213 in the line.
77
+
78
+ Recommended RO flow: get_logwork (what is booked/logged) -> draft_from_commits (the user's git commits as draft lines) -> list_tasks(projectId) -> log_work(text) -> user approval -> log_work(batchId, confirm: true).
79
+ Base every line on the user's commits or on what the user tells you; never invent work. The log_from_commits prompt has the full procedure.
80
+ Fixing existing RO entries (logworkId from get_logwork): submitted/approved entries -> edit_entry (hours, worklog task, type of work, description; apply replaces the entry with a new id, approved immediately); rejected entries -> resubmit_entry; removing an entry -> delete_entry.
81
+ If a preview entry is unresolved: worklog_task_required / task_not_found means fix the worklog task (see availableTaskNames); project reasons mean ask the user to pick a project (projectOverrides) or save a mapping with map_project.`;
82
+
83
+ const server = new McpServer({
84
+ name: 'logwork',
85
+ version: readPackageVersion()
86
+ }, {
87
+ instructions: SERVER_INSTRUCTIONS
88
+ });
89
+
90
+ const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
91
+ const LOCAL_READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
92
+ const WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true };
93
+ const DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true };
94
+ const LOCAL_WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
95
+ const PERIOD_VALUES = ['today', 'yesterday', 'this_week', 'last_week', 'this_month', 'last_month'];
96
+ const TWO_STEP = 'Two calls: (1) without confirm returns a preview with a summary; show it to the user. (2) after explicit approval, call again with only the returned id and confirm: true.';
97
+ const ENTRY_TEXT_DESCRIPTION = 'Preview call only. Weekly log block: day headings like "Monday, 01 Jun 2026", then one entry per line as "+<hours> <worklog task> | <description>", e.g. "+2 Feature Development | Implement maintenance mode UI (SCB-213)". Get valid worklog task names from list_tasks.';
98
+ const LOGWORK_ID_SCHEMA = z.union([z.string().min(1), z.number().int().positive()]).optional().describe('Preview call only. Existing Resource Optimiser logwork entry id (logworkId from get_logwork).');
99
+ const PREVIEW_ID_SCHEMA = z.string().optional().describe('Apply call only. previewId returned by the preview call.');
100
+ const BATCH_ID_SCHEMA = z.string().optional().describe('Apply call only. batchId returned by the preview call.');
101
+ const CONFIRM_SCHEMA = z.boolean().optional().describe('Omit (or false) to preview. Set true only on the apply call, after the user explicitly approved the preview.');
102
+ const TYPE_OF_WORK_SCHEMA = z.enum(['create', 'correct', 'improve', 'other']);
103
+
104
+ server.registerTool('get_logwork', {
105
+ title: 'Get Resource Optimiser logwork',
106
+ description: 'Read-only: booked vs logged Resource Optimiser hours by day and project for a preset period, with entries (logworkId, hours, worklog task, description, status). Use it to see what is missing before log_work, or to find a logworkId for edit_entry, resubmit_entry or delete_entry.',
107
+ annotations: READ_ONLY,
108
+ inputSchema: {
109
+ period: z.enum(PERIOD_VALUES).optional().describe('Preset period. Defaults to today.'),
110
+ project: z.union([z.string(), z.number()]).optional().describe('Project filter by projectMemberId, projectId, name, ticket prefix, or mapping keyword.'),
111
+ includeEntries: z.boolean().optional().describe('Whether to include entry-level detail. Defaults to true.')
112
+ }
113
+ }, withAuthRequiredHandling(async ({ period, project, includeEntries = true }) => {
114
+ const result = await queryLogwork({ period, project, includeEntries });
115
+ return formatToolResponse(result);
116
+ }));
117
+
118
+ server.registerTool('list_projects', {
119
+ title: 'List RO projects and mappings',
120
+ description: 'Read-only: Resource Optimiser project memberships (projectMemberId, projectId, projectName) and local ticket/keyword project mappings. Use projectId with list_tasks, and projectMemberId with log_work projectOverrides or map_project.',
121
+ annotations: READ_ONLY,
122
+ inputSchema: {}
123
+ }, withAuthRequiredHandling(async () => formatToolResponse(await listLogworkProjects())));
124
+
125
+ server.registerTool('list_tasks', {
126
+ title: 'List RO worklog tasks',
127
+ description: 'Read-only: the Resource Optimiser worklog task catalog (project tasks plus shared defaults) for one project, with id, name, type_of_work and a description of when to use each. Every RO entry needs one of these names (or "#<id>") before "|" in "+<hours> <worklog task> | <description>".',
128
+ annotations: READ_ONLY,
129
+ inputSchema: {
130
+ projectId: z.union([z.number().int().positive(), z.string().regex(/^\d+$/)]).describe('Resource Optimiser projectId from list_projects, get_logwork, or a preview matchedProject.')
131
+ }
132
+ }, withAuthRequiredHandling(async ({ projectId }) => formatToolResponse(await listWorklogTasks({ projectId }))));
133
+
134
+ server.registerTool('draft_from_commits', {
135
+ title: 'Draft logwork from git commits',
136
+ description: 'Read-only: read the user\'s own git commits (all branches, git user.email) in one or more repos for a period, group them per day by repo + ticket + kind of work, suggest an RO worklog task per group (feat -> Feature Development, fix -> Bug Fix, refactor/review -> Code Review & Refactoring, test -> Testing Activities, ci/build/release -> Deployment & Release, docs -> Requirements & Design), and split each day\'s remaining RO hours (booked - logged) across the groups. Returns draftText in the log_work line format. Review it with the user, check task names with list_tasks, then pass it to log_work.',
137
+ annotations: READ_ONLY,
138
+ inputSchema: {
139
+ period: z.enum(PERIOD_VALUES).optional().describe('Preset period. Defaults to today.'),
140
+ repos: z.array(z.string().min(1)).max(20).optional().describe('Absolute paths of the git repos the user worked in (for example the open workspace folders). Defaults to the MCP server working directory.'),
141
+ author: z.string().min(1).optional().describe('Git author filter (email or name). Defaults to each repo\'s git config user.email.'),
142
+ useBookings: z.boolean().optional().describe('Split hours by RO remaining booked time per day (default true). When false or RO is unavailable, a weekday counts as 8h.')
143
+ }
144
+ }, withAuthRequiredHandling(async ({ period, repos, author, useBookings = true }) => {
145
+ return formatToolResponse(await draftLogworkFromCommits({ period, repos, author, useBookings }));
146
+ }));
147
+
148
+ server.registerTool('log_work', {
149
+ title: 'Log work to Resource Optimiser',
150
+ description: `Resource Optimiser only: create new RO entries (worklog task + hours + date + type of work + description) from a weekly logwork block. ${TWO_STEP} The preview resolves the project (bookings, mappings, ticket prefixes) and worklog task per line; unresolved lines block apply. Use log_jira_work for Jira.`,
151
+ annotations: WRITE,
152
+ inputSchema: {
153
+ text: z.string().min(1).optional().describe(ENTRY_TEXT_DESCRIPTION),
154
+ projectOverrides: z.record(z.string(), z.union([z.string(), z.number()])).optional().describe('Preview call only. Map preview entry id (e.g. "2026-06-01-01") to the projectMemberId the user chose for an unresolved entry.'),
155
+ defaultWorklogTask: z.string().min(1).optional().describe('Preview call only. Worklog task name (or "#<id>") for lines without "|" whose text is not a catalog task; that text becomes the description. Example: "Feature Development".'),
156
+ typeOfWork: TYPE_OF_WORK_SCHEMA.optional().describe('Preview call only. Override type_of_work for every entry. When omitted, each entry takes its worklog task type, else "other".'),
157
+ batchId: BATCH_ID_SCHEMA,
158
+ allowUnbooked: z.boolean().optional().describe('Apply call only. Allow entries resolved to a project membership without a booking on that date.'),
159
+ confirm: CONFIRM_SCHEMA
160
+ }
161
+ }, withAuthRequiredHandling(async ({ text, projectOverrides, defaultWorklogTask, typeOfWork, batchId, allowUnbooked = false, confirm }) => {
162
+ prunePreviewCache(previews);
163
+ if (confirm === true) {
164
+ assertApplyOnly('log_work', { text, projectOverrides, defaultWorklogTask, typeOfWork });
165
+ const approvedBatch = consumeApprovedBatch({ cache: previews, batchId });
166
+ return formatToolResponse(await applyLogworkBatch({ batch: approvedBatch, confirm, allowUnbooked }));
167
+ }
168
+ requirePreviewInput('log_work', { text });
169
+ const preview = await previewLogworkBatch({ text, projectOverrides: projectOverrides || {}, typeOfWork, defaultWorklogTask });
170
+ setCachedPreview(previews, preview);
171
+ return formatToolResponse(preview);
172
+ }));
173
+
174
+ server.registerTool('edit_entry', {
175
+ title: 'Edit an RO entry',
176
+ description: `Resource Optimiser only: change the hours, worklog task, type of work and/or description of one submitted or approved entry (logworkId from get_logwork). Project and date never change. ${TWO_STEP} RO has no staff edit API, so apply replaces the entry: it creates the corrected entry, then deletes the original (rolled back if the delete fails). The new entry gets a new id and is approved immediately. Rejected entries use resubmit_entry; Jira-created entries are blocked.`,
177
+ annotations: DESTRUCTIVE,
178
+ inputSchema: {
179
+ logworkId: LOGWORK_ID_SCHEMA,
180
+ hours: z.number().positive().max(24).optional().describe('Preview call only. New hours; kept when omitted.'),
181
+ taskName: z.string().min(1).max(1_000).optional().describe('Preview call only. New worklog task: an exact catalog name from list_tasks (for example "Bug Fix") or "#<id>". This is the RO task category, not the description. Kept when omitted.'),
182
+ description: z.string().min(1).max(1_000).optional().describe('Preview call only. New description: what was done, ideally with ticket keys (for example "Fix date picker timezone (SAP-42)"). Kept when omitted.'),
183
+ typeOfWork: TYPE_OF_WORK_SCHEMA.optional().describe('Preview call only. New type of work. When omitted it is kept, or follows the new task catalog type when taskName changes the task.'),
184
+ previewId: PREVIEW_ID_SCHEMA,
185
+ confirm: CONFIRM_SCHEMA
186
+ }
187
+ }, withAuthRequiredHandling(async ({ logworkId, hours, taskName, description, typeOfWork, previewId, confirm }) => {
188
+ prunePreviewCache(roEditPreviews);
189
+ if (confirm === true) {
190
+ assertApplyOnly('edit_entry', { hours, taskName, description, typeOfWork });
191
+ const approvedPreview = consumeApprovedRoEditPreview({ cache: roEditPreviews, previewId, toolName: 'edit_entry' });
192
+ return formatToolResponse(await applyRoLogworkEdit({ preview: approvedPreview, confirm }));
193
+ }
194
+ requirePreviewInput('edit_entry', { logworkId });
195
+ const preview = await previewRoLogworkEdit({ logworkId, hours, taskName, description, typeOfWork });
196
+ setCachedRoEditPreview(roEditPreviews, preview);
197
+ return formatToolResponse(preview);
198
+ }));
199
+
200
+ server.registerTool('resubmit_entry', {
201
+ title: 'Resubmit a rejected RO entry',
202
+ description: `Resource Optimiser only: fix and resubmit one rejected entry (status rejected in get_logwork) with optional new hours, type of work, description and a note to the PM. ${TWO_STEP}`,
203
+ annotations: WRITE,
204
+ inputSchema: {
205
+ logworkId: LOGWORK_ID_SCHEMA,
206
+ hours: z.number().positive().optional().describe('Preview call only. New hours; defaults to the rejected entry hours.'),
207
+ typeOfWork: TYPE_OF_WORK_SCHEMA.optional().describe('Preview call only. New type of work; defaults to the rejected entry value.'),
208
+ noteToPm: z.string().max(500).optional().describe('Preview call only. Note to the project manager explaining the fix.'),
209
+ description: z.string().max(1_000).optional().describe('Preview call only. New description; defaults to the rejected entry description.'),
210
+ previewId: PREVIEW_ID_SCHEMA,
211
+ confirm: CONFIRM_SCHEMA
212
+ }
213
+ }, withAuthRequiredHandling(async ({ logworkId, hours, typeOfWork, noteToPm, description, previewId, confirm }) => {
214
+ prunePreviewCache(roResubmitPreviews);
215
+ if (confirm === true) {
216
+ assertApplyOnly('resubmit_entry', { hours, typeOfWork, noteToPm, description });
217
+ const approvedPreview = consumeApprovedRoEditPreview({ cache: roResubmitPreviews, previewId, toolName: 'resubmit_entry' });
218
+ return formatToolResponse(await applyRoLogworkResubmit({ preview: approvedPreview, confirm }));
219
+ }
220
+ requirePreviewInput('resubmit_entry', { logworkId });
221
+ const preview = await previewRoLogworkResubmit({ logworkId, hours, typeOfWork, noteToPm, description });
222
+ setCachedRoEditPreview(roResubmitPreviews, preview);
223
+ return formatToolResponse(preview);
224
+ }));
225
+
226
+ server.registerTool('delete_entry', {
227
+ title: 'Delete an RO entry',
228
+ description: `Resource Optimiser only: delete one submitted or approved entry (logworkId from get_logwork). Deleting needs no PM approval. Rejected entries cannot be deleted; use resubmit_entry. ${TWO_STEP}`,
229
+ annotations: DESTRUCTIVE,
230
+ inputSchema: {
231
+ logworkId: LOGWORK_ID_SCHEMA,
232
+ previewId: PREVIEW_ID_SCHEMA,
233
+ confirm: CONFIRM_SCHEMA
234
+ }
235
+ }, withAuthRequiredHandling(async ({ logworkId, previewId, confirm }) => {
236
+ prunePreviewCache(roDeletePreviews);
237
+ if (confirm === true) {
238
+ const approvedPreview = consumeApprovedRoEditPreview({ cache: roDeletePreviews, previewId, toolName: 'delete_entry' });
239
+ return formatToolResponse(await applyRoLogworkDelete({ preview: approvedPreview, confirm }));
240
+ }
241
+ requirePreviewInput('delete_entry', { logworkId });
242
+ const preview = await previewRoLogworkDelete({ logworkId });
243
+ setCachedRoEditPreview(roDeletePreviews, preview);
244
+ return formatToolResponse(preview);
245
+ }));
246
+
247
+ server.registerTool('log_jira_work', {
248
+ title: 'Log work to Jira',
249
+ description: `Jira only: create Jira worklogs from a weekly logwork block. Each line needs exactly one issue key; the description after "|" (or the whole text) becomes the worklog comment, and duplicates are detected. ${TWO_STEP} Use log_work for Resource Optimiser.`,
250
+ annotations: WRITE,
251
+ inputSchema: {
252
+ text: z.string().min(1).optional().describe(`${ENTRY_TEXT_DESCRIPTION} Each line must contain exactly one Jira issue key such as SCB-213.`),
253
+ batchId: BATCH_ID_SCHEMA,
254
+ confirm: CONFIRM_SCHEMA
255
+ }
256
+ }, withJiraAuthRequiredHandling(async ({ text, batchId, confirm }) => {
257
+ prunePreviewCache(jiraPreviews);
258
+ if (confirm === true) {
259
+ assertApplyOnly('log_jira_work', { text });
260
+ const approvedBatch = consumeApprovedJiraBatch({ cache: jiraPreviews, batchId });
261
+ return formatToolResponse(await applyJiraWorklogBatch({ batch: approvedBatch, confirm }));
262
+ }
263
+ requirePreviewInput('log_jira_work', { text });
264
+ const preview = await previewJiraWorklogBatch({ text });
265
+ setCachedJiraPreview(jiraPreviews, preview);
266
+ return formatToolResponse(preview);
267
+ }));
268
+
269
+ server.registerTool('get_jira_issue', {
270
+ title: 'Get Jira issue',
271
+ description: 'Read-only: read a Jira issue (key, summary, status, project) from the configured self-hosted Jira. Useful to write an accurate logwork description.',
272
+ annotations: READ_ONLY,
273
+ inputSchema: {
274
+ issueKey: z.string().regex(/^[A-Z][A-Z0-9]+-\d+$/).describe('Jira issue key, for example SCB-213.')
275
+ }
276
+ }, withJiraAuthRequiredHandling(async ({ issueKey }) => {
277
+ const session = await getStoredJiraSession();
278
+ const issue = await getJiraIssue(session.token, issueKey, { baseUrl: session.baseUrl });
279
+ return formatToolResponse({
280
+ status: 'ok',
281
+ issue,
282
+ summary: `${issue.key}: ${issue.summary} (${issue.status || 'unknown status'})`
283
+ });
284
+ }));
285
+
286
+ server.registerTool('reconcile', {
287
+ title: 'Compare RO and Jira',
288
+ description: 'Read-only: compare Resource Optimiser and Jira worklog hours by day for a preset period. Returns mismatches and correction suggestions, but never writes either system.',
289
+ annotations: READ_ONLY,
290
+ inputSchema: {
291
+ period: z.enum(PERIOD_VALUES).optional().describe('Preset period. Defaults to this_week.')
292
+ }
293
+ }, withRoAndJiraAuthRequiredHandling(async ({ period = 'this_week' }) => formatToolResponse(await reconcileLogwork({ period }))));
294
+
295
+ server.registerTool('get_history', {
296
+ title: 'Local apply history',
297
+ description: 'Read-only: the local history of applied RO and Jira writes (date, hours, task, description, project, status). Target both means entries applied from a combined RO + Jira flow. Never returns credentials or raw API responses.',
298
+ annotations: LOCAL_READ_ONLY,
299
+ inputSchema: {
300
+ target: z.enum(['ro', 'jira', 'both']).optional().describe('Optional target filter.'),
301
+ limit: z.number().int().min(1).max(100).optional().describe('Maximum records to return. Defaults to 20.')
302
+ }
303
+ }, async ({ target, limit = 20 }) => formatToolResponse(await queryApplyLedger({ target, limit })));
304
+
305
+ server.registerTool('map_project', {
306
+ title: 'Save a project mapping',
307
+ description: 'Save a local ticket-prefix/keyword -> RO project mapping so future log_work previews resolve the project automatically. Writes .logwork.json; requires confirm: true after the user approved the mapping.',
308
+ annotations: { ...LOCAL_WRITE, idempotentHint: true },
309
+ inputSchema: {
310
+ projectMemberId: z.union([z.string(), z.number()]).optional().describe('RO projectMemberId from list_projects or preview setupSuggestions.'),
311
+ projectName: z.string().optional().describe('Project name to match against the user\'s memberships when projectMemberId is not given.'),
312
+ tickets: z.array(z.string()).min(1).describe('Ticket prefixes to map, for example ["SCB"].'),
313
+ keywords: z.array(z.string()).optional().describe('Optional task keywords to map to the project.'),
314
+ scope: z.enum(['user', 'project']).optional().describe('user (default): ~/.logwork-cli/.logwork.json for this machine. project: .logwork.json in the current repo.'),
315
+ confirm: z.boolean().describe('Must be true, and only after explicit user approval.')
316
+ }
317
+ }, withAuthRequiredHandling(async ({ projectMemberId, projectName, tickets, keywords = [], scope = 'user', confirm }) => {
318
+ const result = await upsertProjectMapping({ projectMemberId, projectName, tickets, keywords, scope, confirm });
319
+ return formatToolResponse(result);
320
+ }));
321
+
322
+ server.registerTool('login', {
323
+ title: 'Start login',
324
+ description: 'Call when a tool returns authRequired (target "ro") or jira_auth_required (target "jira"). For RO it opens or explains a terminal login where the user types email, password and 2FA; for Jira it explains how to save a Personal Access Token from the terminal. This tool never accepts credentials.',
325
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
326
+ inputSchema: {
327
+ target: z.enum(['ro', 'jira']).optional().describe('ro (default): Resource Optimiser. jira: Jira Personal Access Token.')
328
+ }
329
+ }, async ({ target = 'ro' }) => {
330
+ if (target === 'jira') {
331
+ return formatToolResponse({
332
+ status: 'jira_auth_required',
333
+ authRequired: true,
334
+ command: 'logwork login jira',
335
+ summary: 'Run `logwork login jira` in a terminal and paste a Jira Personal Access Token there, then retry the tool.'
336
+ });
337
+ }
338
+ return formatToolResponse(await startAuthLoginTerminal());
339
+ });
340
+
341
+ server.registerTool('reminder', {
342
+ title: 'Logwork reminder',
343
+ description: 'The native macOS/Windows weekday logwork reminder. action "status" is read-only. "enable", "disable" and "test" (shows a sample notification) change the OS scheduler or display a notification and require confirm: true after explicit user approval. No credentials are stored in the schedule.',
344
+ annotations: LOCAL_WRITE,
345
+ inputSchema: {
346
+ action: z.enum(['status', 'enable', 'disable', 'test']).describe('status: show config and scheduler state. enable/disable: install or remove the schedule. test: send a sample notification.'),
347
+ time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).optional().describe('enable only. Local time in 24-hour HH:mm. Defaults to 17:00.'),
348
+ target: z.enum(['ro', 'jira', 'both']).optional().describe('enable/test only. What to check. Defaults to both (enable) or the configured target (test).'),
349
+ confirm: z.boolean().optional().describe('Required (true) for enable, disable and test, after explicit user approval.')
350
+ }
351
+ }, async ({ action, time, target, confirm }) => {
352
+ if (action === 'status') {
353
+ return formatToolResponse(await getLogworkReminderStatus());
354
+ }
355
+ if (confirm !== true) {
356
+ throw new Error(`reminder action "${action}" requires confirm: true after user approval.`);
357
+ }
358
+ if (action !== 'enable' && time) {
359
+ throw new Error('time is only valid when enabling the reminder.');
360
+ }
361
+ if (action === 'disable' && target) {
362
+ throw new Error('target is not valid when disabling the reminder.');
363
+ }
364
+ if (action === 'enable') {
365
+ return formatToolResponse(await enableLogworkReminder({ time, target, confirm }));
366
+ }
367
+ if (action === 'disable') {
368
+ return formatToolResponse(await disableLogworkReminder({ confirm }));
369
+ }
370
+ return formatToolResponse(await testLogworkReminder({ target, confirm }));
371
+ });
372
+
373
+ server.registerTool('update', {
374
+ title: 'Check or install logwork-cli updates',
375
+ description: 'action "check" (read-only) compares the running version with npm latest (24-hour cache unless force). action "install" installs that exact latest version, keeping local state and credentials; it requires confirm: true after explicit approval, and MCP must be reconnected afterwards.',
376
+ annotations: WRITE,
377
+ inputSchema: {
378
+ action: z.enum(['check', 'install']).optional().describe('check (default) or install.'),
379
+ force: z.boolean().optional().describe('check only. Ignore the local cache and query npm now.'),
380
+ version: z.string().regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/).optional().describe('install only. Exact SemVer the user approved; must equal npm latest.'),
381
+ confirm: z.boolean().optional().describe('install only. Must be true after explicit user approval.')
382
+ }
383
+ }, async ({ action = 'check', force = false, version, confirm }) => {
384
+ if (action === 'check') {
385
+ return formatToolResponse(await checkForUpdates({ force }));
386
+ }
387
+ if (confirm !== true) {
388
+ throw new Error('update action "install" requires confirm: true after user approval.');
389
+ }
390
+ return formatToolResponse(await installUpdate({ version, confirm, stdio: 'pipe' }));
391
+ });
392
+
393
+ server.registerPrompt('log_from_commits', {
394
+ title: 'Log work from my commits',
395
+ description: 'Step-by-step procedure for logging accurate Resource Optimiser (and optionally Jira) work from the user\'s git commits.',
396
+ argsSchema: {
397
+ period: z.string().optional().describe('today, yesterday, this_week, last_week, this_month or last_month. Defaults to today.'),
398
+ repos: z.string().optional().describe('Comma-separated absolute repo paths. Defaults to the open workspace.')
399
+ }
400
+ }, ({ period, repos }) => ({
401
+ messages: [{
402
+ role: 'user',
403
+ content: {
404
+ type: 'text',
405
+ text: [
406
+ readLogworkSkill(),
407
+ '',
408
+ `Period: ${period || 'today'}.`,
409
+ repos ? `Repos: ${repos}.` : 'Repos: the folders open in this workspace (ask me if there are others).',
410
+ 'Start with get_logwork, then draft_from_commits.'
411
+ ].join('\n')
412
+ }
413
+ }]
414
+ }));
415
+
416
+ export async function startMcpServer() {
417
+ const transport = new StdioServerTransport();
418
+ await server.connect(transport);
419
+ }
420
+
421
+ if (isMainModule(import.meta.url)) {
422
+ startMcpServer().catch((error) => {
423
+ console.error('logwork MCP server error:', error);
424
+ process.exit(1);
425
+ });
426
+ }
427
+
428
+ // Apply calls carry only the id + confirm. Content fields on an apply call are
429
+ // rejected instead of ignored, so an agent never believes a changed value was
430
+ // applied when the cached (approved) preview is what gets written.
431
+ function assertApplyOnly(toolName, fields) {
432
+ const extra = Object.entries(fields).filter(([, value]) => value !== undefined).map(([key]) => key);
433
+ if (extra.length) {
434
+ throw new Error(`${toolName} with confirm: true applies the approved preview as-is; do not pass ${extra.join(', ')}. To change values, call ${toolName} without confirm to preview again.`);
435
+ }
436
+ }
437
+
438
+ function requirePreviewInput(toolName, fields) {
439
+ const missing = Object.entries(fields).filter(([, value]) => value === undefined || value === null || value === '').map(([key]) => key);
440
+ if (missing.length) {
441
+ throw new Error(`${toolName} preview needs ${missing.join(', ')}. To apply an approved preview, pass its id with confirm: true.`);
442
+ }
443
+ }
444
+
445
+ export function setCachedPreview(cache, preview, now = Date.now()) {
446
+ prunePreviewCache(cache, now);
447
+ const cachedPreview = clonePreview(preview);
448
+ cache.set(cachedPreview.batchId, {
449
+ preview: cachedPreview,
450
+ fingerprint: createPreviewFingerprint(cachedPreview),
451
+ expiresAt: now + PREVIEW_TTL_MS
452
+ });
453
+ prunePreviewCache(cache, now);
454
+ }
455
+
456
+ export function getCachedPreview(cache, batchId, now = Date.now()) {
457
+ const cached = getCachedPreviewRecord(cache, batchId, now);
458
+ return cached ? clonePreview(cached.preview) : null;
459
+ }
460
+
461
+ export function setCachedJiraPreview(cache, preview, now = Date.now()) {
462
+ prunePreviewCache(cache, now);
463
+ const cachedPreview = clonePreview(preview);
464
+ cache.set(cachedPreview.batchId, {
465
+ preview: cachedPreview,
466
+ fingerprint: createJiraPreviewFingerprint(cachedPreview),
467
+ expiresAt: now + PREVIEW_TTL_MS
468
+ });
469
+ prunePreviewCache(cache, now);
470
+ }
471
+
472
+ export function getCachedJiraPreview(cache, batchId, now = Date.now()) {
473
+ const cached = getCachedPreviewRecord(cache, batchId, now);
474
+ return cached ? clonePreview(cached.preview) : null;
475
+ }
476
+
477
+ export function setCachedRoEditPreview(cache, preview, now = Date.now()) {
478
+ prunePreviewCache(cache, now);
479
+ const cachedPreview = clonePreview(preview);
480
+ cache.set(cachedPreview.previewId, {
481
+ preview: cachedPreview,
482
+ fingerprint: createRoEditPreviewFingerprint(cachedPreview),
483
+ expiresAt: now + PREVIEW_TTL_MS
484
+ });
485
+ prunePreviewCache(cache, now);
486
+ }
487
+
488
+ export function getCachedRoEditPreview(cache, previewId, now = Date.now()) {
489
+ const cached = getCachedPreviewRecord(cache, previewId, now);
490
+ return cached ? clonePreview(cached.preview) : null;
491
+ }
492
+
493
+ function getCachedPreviewRecord(cache, batchId, now = Date.now()) {
494
+ const cached = cache.get(batchId);
495
+ if (!cached) {
496
+ return null;
497
+ }
498
+
499
+ if (cached.expiresAt <= now) {
500
+ cache.delete(batchId);
501
+ return null;
502
+ }
503
+
504
+ return cached;
505
+ }
506
+
507
+ export function prunePreviewCache(cache, now = Date.now()) {
508
+ for (const [batchId, cached] of cache.entries()) {
509
+ if (cached.expiresAt <= now) {
510
+ cache.delete(batchId);
511
+ }
512
+ }
513
+
514
+ while (cache.size > MAX_PREVIEWS) {
515
+ const oldestBatchId = cache.keys().next().value;
516
+ if (oldestBatchId === undefined) {
517
+ break;
518
+ }
519
+ cache.delete(oldestBatchId);
520
+ }
521
+ }
522
+
523
+ export function resolveApprovedBatch({
524
+ cache,
525
+ batchId,
526
+ batch,
527
+ now = Date.now()
528
+ }) {
529
+ if (!batchId) {
530
+ throw new Error('log_work with confirm: true requires the batchId returned by its preview call. Preview again and apply using the returned batchId.');
531
+ }
532
+
533
+ if (batch?.batchId && batch.batchId !== batchId) {
534
+ throw new Error(`Approved batch mismatch: batchId ${batchId} does not match batch.batchId ${batch.batchId}. Preview log_work again before applying.`);
535
+ }
536
+
537
+ const cached = getCachedPreviewRecord(cache, batchId, now);
538
+ if (!cached) {
539
+ throw new Error('No cached log_work preview for this batchId (expired after 1 hour, already applied, or the server restarted). Preview again and apply using the returned batchId.');
540
+ }
541
+
542
+ if (batch && createPreviewFingerprint(batch) !== cached.fingerprint) {
543
+ throw new Error('Approved batch content changed after preview. Preview log_work again and apply using the returned batchId.');
544
+ }
545
+
546
+ return clonePreview(cached.preview);
547
+ }
548
+
549
+ export function consumeApprovedBatch({
550
+ cache,
551
+ batchId,
552
+ batch,
553
+ now = Date.now()
554
+ }) {
555
+ const approvedBatch = resolveApprovedBatch({
556
+ cache,
557
+ batchId,
558
+ batch,
559
+ now
560
+ });
561
+ cache.delete(batchId);
562
+ return approvedBatch;
563
+ }
564
+
565
+ export function resolveApprovedJiraBatch({
566
+ cache,
567
+ batchId,
568
+ batch,
569
+ now = Date.now()
570
+ }) {
571
+ if (!batchId) {
572
+ throw new Error('log_jira_work with confirm: true requires the batchId returned by its preview call. Preview again and apply using the returned batchId.');
573
+ }
574
+
575
+ if (batch?.batchId && batch.batchId !== batchId) {
576
+ throw new Error(`Approved Jira batch mismatch: batchId ${batchId} does not match batch.batchId ${batch.batchId}. Preview log_jira_work again before applying.`);
577
+ }
578
+
579
+ const cached = getCachedPreviewRecord(cache, batchId, now);
580
+ if (!cached) {
581
+ throw new Error('No cached log_jira_work preview for this batchId (expired after 1 hour, already applied, or the server restarted). Preview again and apply using the returned batchId.');
582
+ }
583
+
584
+ if (batch && createJiraPreviewFingerprint(batch) !== cached.fingerprint) {
585
+ throw new Error('Approved Jira batch content changed after preview. Preview log_jira_work again and apply using the returned batchId.');
586
+ }
587
+
588
+ return clonePreview(cached.preview);
589
+ }
590
+
591
+ export function consumeApprovedJiraBatch({
592
+ cache,
593
+ batchId,
594
+ batch,
595
+ now = Date.now()
596
+ }) {
597
+ const approvedBatch = resolveApprovedJiraBatch({
598
+ cache,
599
+ batchId,
600
+ batch,
601
+ now
602
+ });
603
+ cache.delete(batchId);
604
+ return approvedBatch;
605
+ }
606
+
607
+ export function resolveApprovedRoEditPreview({
608
+ cache,
609
+ previewId,
610
+ preview,
611
+ toolName = 'edit_entry',
612
+ now = Date.now()
613
+ }) {
614
+ if (!previewId) {
615
+ throw new Error(`${toolName} with confirm: true requires the previewId returned by its preview call. Preview again and apply using the returned previewId.`);
616
+ }
617
+ if (preview?.previewId && preview.previewId !== previewId) {
618
+ throw new Error(`Approved preview mismatch: previewId ${previewId} does not match preview.previewId ${preview.previewId}. Preview ${toolName} again before applying.`);
619
+ }
620
+
621
+ const cached = getCachedPreviewRecord(cache, previewId, now);
622
+ if (!cached) {
623
+ throw new Error(`No cached ${toolName} preview for this previewId (expired after 1 hour, already applied, or the server restarted). Preview again before applying.`);
624
+ }
625
+ if (preview && createRoEditPreviewFingerprint(preview) !== cached.fingerprint) {
626
+ throw new Error(`Approved ${toolName} preview changed after preview. Preview again before applying.`);
627
+ }
628
+ return clonePreview(cached.preview);
629
+ }
630
+
631
+ export function consumeApprovedRoEditPreview({
632
+ cache,
633
+ previewId,
634
+ preview,
635
+ toolName = 'edit_entry',
636
+ now = Date.now()
637
+ }) {
638
+ const approvedPreview = resolveApprovedRoEditPreview({
639
+ cache,
640
+ previewId,
641
+ preview,
642
+ toolName,
643
+ now
644
+ });
645
+ cache.delete(previewId);
646
+ return approvedPreview;
647
+ }
648
+
649
+ export function assertNoFinalProjectOverrides(projectOverrides = {}) {
650
+ if (!projectOverrides || typeof projectOverrides !== 'object' || !Object.keys(projectOverrides).length) {
651
+ return;
652
+ }
653
+
654
+ throw new Error('projectOverrides belong to the log_work preview call, so the approved batchId covers the final project selection. Preview again with projectOverrides.');
655
+ }
656
+
657
+ export function createPreviewFingerprint(preview = {}) {
658
+ return JSON.stringify({
659
+ batchId: textOrNull(preview.batchId),
660
+ status: textOrNull(preview.status),
661
+ errors: arrayOrEmpty(preview.errors).map((error) => ({
662
+ line: valueOrNull(error?.line),
663
+ message: textOrNull(error?.message)
664
+ })),
665
+ entries: arrayOrEmpty(preview.entries).map((entry) => ({
666
+ id: textOrNull(entry?.id),
667
+ date: textOrNull(entry?.date),
668
+ hours: numberOrNull(entry?.hours),
669
+ taskName: textOrNull(entry?.taskName),
670
+ tickets: arrayOrEmpty(entry?.tickets).map((ticket) => String(ticket)),
671
+ status: textOrNull(entry?.status),
672
+ reason: textOrNull(entry?.reason),
673
+ confidence: numberOrNull(entry?.confidence),
674
+ matchedProject: projectFingerprint(entry?.matchedProject),
675
+ booked: booleanOrNull(entry?.booked),
676
+ requiresAllowUnbooked: booleanOrNull(entry?.requiresAllowUnbooked)
677
+ }))
678
+ });
679
+ }
680
+
681
+ export function createJiraPreviewFingerprint(preview = {}) {
682
+ return JSON.stringify({
683
+ batchId: textOrNull(preview.batchId),
684
+ status: textOrNull(preview.status),
685
+ errors: arrayOrEmpty(preview.errors).map((error) => ({
686
+ line: valueOrNull(error?.line),
687
+ message: textOrNull(error?.message)
688
+ })),
689
+ entries: arrayOrEmpty(preview.entries).map((entry) => ({
690
+ id: textOrNull(entry?.id),
691
+ date: textOrNull(entry?.date),
692
+ hours: numberOrNull(entry?.hours),
693
+ taskName: textOrNull(entry?.taskName),
694
+ tickets: arrayOrEmpty(entry?.tickets).map((ticket) => String(ticket)),
695
+ issueKey: textOrNull(entry?.issueKey),
696
+ status: textOrNull(entry?.status),
697
+ reason: textOrNull(entry?.reason),
698
+ issue: jiraIssueFingerprint(entry?.issue),
699
+ worklog: jiraWorklogPayloadFingerprint(entry?.worklog),
700
+ duplicate: jiraDuplicateFingerprint(entry?.duplicate)
701
+ }))
702
+ });
703
+ }
704
+
705
+ function clonePreview(preview) {
706
+ if (typeof structuredClone === 'function') {
707
+ return structuredClone(preview);
708
+ }
709
+ return JSON.parse(JSON.stringify(preview));
710
+ }
711
+
712
+ function projectFingerprint(project) {
713
+ if (!project || typeof project !== 'object') {
714
+ return null;
715
+ }
716
+
717
+ return {
718
+ projectMemberId: idOrNull(project.projectMemberId),
719
+ projectId: idOrNull(project.projectId),
720
+ projectName: textOrNull(project.projectName)
721
+ };
722
+ }
723
+
724
+ function jiraIssueFingerprint(issue) {
725
+ if (!issue || typeof issue !== 'object') {
726
+ return null;
727
+ }
728
+
729
+ return {
730
+ key: textOrNull(issue.key),
731
+ summary: textOrNull(issue.summary),
732
+ status: textOrNull(issue.status),
733
+ issueType: textOrNull(issue.issueType),
734
+ project: issue.project && typeof issue.project === 'object' ? {
735
+ key: textOrNull(issue.project.key),
736
+ name: textOrNull(issue.project.name)
737
+ } : null
738
+ };
739
+ }
740
+
741
+ function jiraWorklogPayloadFingerprint(worklog) {
742
+ if (!worklog || typeof worklog !== 'object') {
743
+ return null;
744
+ }
745
+
746
+ return {
747
+ started: textOrNull(worklog.started),
748
+ timeSpentSeconds: numberOrNull(worklog.timeSpentSeconds),
749
+ comment: textOrNull(worklog.comment)
750
+ };
751
+ }
752
+
753
+ function jiraDuplicateFingerprint(worklog) {
754
+ if (!worklog || typeof worklog !== 'object') {
755
+ return null;
756
+ }
757
+
758
+ return {
759
+ id: textOrNull(worklog.id),
760
+ started: textOrNull(worklog.started),
761
+ timeSpentSeconds: numberOrNull(worklog.timeSpentSeconds),
762
+ comment: textOrNull(worklog.comment)
763
+ };
764
+ }
765
+
766
+ function arrayOrEmpty(value) {
767
+ return Array.isArray(value) ? value : [];
768
+ }
769
+
770
+ function textOrNull(value) {
771
+ return value === undefined || value === null ? null : String(value);
772
+ }
773
+
774
+ function idOrNull(value) {
775
+ return value === undefined || value === null || String(value).trim() === ''
776
+ ? null
777
+ : String(value);
778
+ }
779
+
780
+ function valueOrNull(value) {
781
+ return value === undefined ? null : value;
782
+ }
783
+
784
+ function numberOrNull(value) {
785
+ const number = Number(value);
786
+ return Number.isFinite(number) ? number : null;
787
+ }
788
+
789
+ function booleanOrNull(value) {
790
+ return value === undefined || value === null ? null : Boolean(value);
791
+ }
792
+
793
+ function withAuthRequiredHandling(handler) {
794
+ return async (args) => {
795
+ try {
796
+ return await handler(args);
797
+ } catch (error) {
798
+ if (isAuthRequiredError(error)) {
799
+ return formatToolResponse(authRequiredPayload(error));
800
+ }
801
+ throw error;
802
+ }
803
+ };
804
+ }
805
+
806
+ function withJiraAuthRequiredHandling(handler) {
807
+ return async (args) => {
808
+ try {
809
+ return await handler(args);
810
+ } catch (error) {
811
+ if (isJiraAuthRequiredError(error) || (error instanceof ApiError && error.status === 401)) {
812
+ return formatToolResponse(jiraAuthRequiredPayload(error));
813
+ }
814
+ throw error;
815
+ }
816
+ };
817
+ }
818
+
819
+ function withRoAndJiraAuthRequiredHandling(handler) {
820
+ return async (args) => {
821
+ try {
822
+ return await handler(args);
823
+ } catch (error) {
824
+ if (isAuthRequiredError(error)) {
825
+ return formatToolResponse(authRequiredPayload(error));
826
+ }
827
+ if (isJiraAuthRequiredError(error) || (error instanceof ApiError && error.status === 401)) {
828
+ return formatToolResponse(jiraAuthRequiredPayload(error));
829
+ }
830
+ throw error;
831
+ }
832
+ };
833
+ }