logwork-cli 0.0.0-stage → 1.0.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 (91) hide show
  1. package/LICENSE +11 -0
  2. package/README.md +287 -2
  3. package/RELEASE.md +89 -0
  4. package/bin/logwork.mjs +101 -0
  5. package/commands/auth.mjs +227 -0
  6. package/commands/delete.mjs +69 -0
  7. package/commands/doctor.mjs +24 -0
  8. package/commands/edit.mjs +88 -0
  9. package/commands/history.mjs +62 -0
  10. package/commands/query.mjs +52 -0
  11. package/commands/quick.mjs +163 -0
  12. package/commands/reconcile.mjs +55 -0
  13. package/commands/release.mjs +77 -0
  14. package/commands/reminder.mjs +172 -0
  15. package/commands/setup.mjs +504 -0
  16. package/commands/tui.mjs +50 -0
  17. package/commands/update.mjs +161 -0
  18. package/docs/advanced.md +334 -0
  19. package/docs/mcp-setup.md +347 -0
  20. package/docs/operations-guide.md +587 -0
  21. package/docs/recipes.md +259 -0
  22. package/docs/security.md +151 -0
  23. package/examples/mcp/antigravity.mcp_config.json +8 -0
  24. package/examples/mcp/codex.config.toml +5 -0
  25. package/examples/mcp/copilot.mcp.json +9 -0
  26. package/lib/api-auth.mjs +1259 -0
  27. package/lib/api.mjs +1422 -0
  28. package/lib/apply-ledger.mjs +227 -0
  29. package/lib/atomic-file.mjs +24 -0
  30. package/lib/auth-diagnostics.mjs +113 -0
  31. package/lib/auth-errors.mjs +22 -0
  32. package/lib/auth-redaction.mjs +78 -0
  33. package/lib/auth-terminal.mjs +97 -0
  34. package/lib/auth.mjs +323 -0
  35. package/lib/batch-parser.mjs +145 -0
  36. package/lib/batch-workflow.mjs +842 -0
  37. package/lib/config.mjs +191 -0
  38. package/lib/credential-store.mjs +130 -0
  39. package/lib/diagnostics.mjs +307 -0
  40. package/lib/entrypoint.mjs +14 -0
  41. package/lib/file-lock.mjs +44 -0
  42. package/lib/http.mjs +125 -0
  43. package/lib/jira-api.mjs +258 -0
  44. package/lib/jira-auth.mjs +203 -0
  45. package/lib/jira-workflow.mjs +523 -0
  46. package/lib/keychain.mjs +105 -0
  47. package/lib/legacy-migration.mjs +66 -0
  48. package/lib/list-navigation.mjs +19 -0
  49. package/lib/logwork-config.mjs +199 -0
  50. package/lib/logwork-f03-enrich.mjs +41 -0
  51. package/lib/macos.mjs +33 -0
  52. package/lib/manual-apply-state.mjs +35 -0
  53. package/lib/manual-commands.mjs +294 -0
  54. package/lib/manual-drafts.mjs +154 -0
  55. package/lib/manual-ink-app.mjs +1654 -0
  56. package/lib/manual-ink-auth.mjs +349 -0
  57. package/lib/manual-ink-ui.mjs +675 -0
  58. package/lib/manual-input.mjs +21 -0
  59. package/lib/manual-logwork-wizard.mjs +310 -0
  60. package/lib/manual-mcp.mjs +187 -0
  61. package/lib/manual-repl.mjs +1037 -0
  62. package/lib/manual-targets.mjs +78 -0
  63. package/lib/mcp-server.mjs +793 -0
  64. package/lib/os-notification.mjs +105 -0
  65. package/lib/package-info.mjs +17 -0
  66. package/lib/paths.mjs +68 -0
  67. package/lib/project-identity.mjs +66 -0
  68. package/lib/project-mapping-workflow.mjs +309 -0
  69. package/lib/project-resolver.mjs +373 -0
  70. package/lib/query-workflow.mjs +553 -0
  71. package/lib/reconciliation-workflow.mjs +446 -0
  72. package/lib/release-doctor.mjs +448 -0
  73. package/lib/reminder-config.mjs +71 -0
  74. package/lib/reminder-scheduler.mjs +301 -0
  75. package/lib/reminder-service.mjs +118 -0
  76. package/lib/reminder-state.mjs +38 -0
  77. package/lib/reminder-workflow.mjs +299 -0
  78. package/lib/ro-delete-args.mjs +38 -0
  79. package/lib/ro-delete-workflow.mjs +115 -0
  80. package/lib/ro-edit-args.mjs +142 -0
  81. package/lib/ro-edit-workflow.mjs +632 -0
  82. package/lib/ro-entry-reader.mjs +56 -0
  83. package/lib/ro-resubmit-workflow.mjs +134 -0
  84. package/lib/terminal-auth-prompts.mjs +120 -0
  85. package/lib/ui.mjs +133 -0
  86. package/lib/update-service.mjs +350 -0
  87. package/lib/util.mjs +66 -0
  88. package/lib/windows-credential-manager.mjs +260 -0
  89. package/lib/worklog-task-resolver.mjs +113 -0
  90. package/package.json +72 -4
  91. package/setup.sh +22 -0
@@ -0,0 +1,1037 @@
1
+ import { stdin as defaultInput, stdout as defaultOutput } from 'node:process';
2
+ import { confirm, isCancel } from '@clack/prompts';
3
+ import { getStoredAuthStatus, loginResourceOptimiser } from './auth.mjs';
4
+ import { queryApplyLedger } from './apply-ledger.mjs';
5
+ import { isAuthRequiredError } from './auth-errors.mjs';
6
+ import { applyLogworkBatch, previewLogworkBatch } from './batch-workflow.mjs';
7
+ import {
8
+ applyJiraWorklogBatch,
9
+ previewJiraWorklogBatch
10
+ } from './jira-workflow.mjs';
11
+ import {
12
+ getStoredJiraStatus,
13
+ isJiraAuthRequiredError
14
+ } from './jira-auth.mjs';
15
+ import {
16
+ buildLogworkText,
17
+ buildProjectOverrides,
18
+ formatHours,
19
+ parseTaskLine
20
+ } from './manual-logwork-wizard.mjs';
21
+ import {
22
+ BOTH_TARGET,
23
+ jiraLoginInstruction,
24
+ JIRA_TARGET,
25
+ parseTargetToken,
26
+ previewTargets,
27
+ RO_TARGET,
28
+ targetLabel
29
+ } from './manual-targets.mjs';
30
+ import { findManualCommand, formatManualHelp } from './manual-commands.mjs';
31
+ import {
32
+ buildManualApplyConfirmation,
33
+ buildManualUnbookedConfirmation,
34
+ getManualApplyBlocker,
35
+ manualUnbookedEntryCount
36
+ } from './manual-apply-state.mjs';
37
+ import { runManualInkApp } from './manual-ink-app.mjs';
38
+ import { createManualLineReader } from './manual-input.mjs';
39
+ import { generateDiagnosticsReport } from './diagnostics.mjs';
40
+ import {
41
+ formatMcpClientOptions,
42
+ formatMcpSetup,
43
+ resolveMcpClient,
44
+ resolveMcpClientSelection
45
+ } from './manual-mcp.mjs';
46
+ import {
47
+ enrichRoPreviewWithJiraProjects,
48
+ listLogworkProjects,
49
+ upsertProjectMapping
50
+ } from './project-mapping-workflow.mjs';
51
+ import { queryLogwork } from './query-workflow.mjs';
52
+ import { parseRoEditArgs } from './ro-edit-args.mjs';
53
+ import {
54
+ applyRoLogworkEdit,
55
+ previewRoLogworkEdit
56
+ } from './ro-edit-workflow.mjs';
57
+ import {
58
+ normalizeReconciliationPeriod,
59
+ reconcileLogwork
60
+ } from './reconciliation-workflow.mjs';
61
+ import {
62
+ projectIdentityKey,
63
+ projectMatchesFilter,
64
+ sameProjectIdentity
65
+ } from './project-identity.mjs';
66
+
67
+ export const MANUAL_HELP = formatManualHelp();
68
+
69
+ export function createManualSession() {
70
+ return {
71
+ activeTarget: RO_TARGET,
72
+ previews: {
73
+ [RO_TARGET]: null,
74
+ [JIRA_TARGET]: null
75
+ },
76
+ logworkDraft: null
77
+ };
78
+ }
79
+
80
+ export async function runManualRepl({
81
+ input = defaultInput,
82
+ output = defaultOutput,
83
+ cwd = process.cwd(),
84
+ workflows = defaultWorkflows(),
85
+ prompts = createManualPrompts(),
86
+ showIntro = true,
87
+ updateNotice = ''
88
+ } = {}) {
89
+ if (input?.isTTY && output?.isTTY) {
90
+ await runManualInkApp({
91
+ input,
92
+ output,
93
+ cwd,
94
+ workflows,
95
+ controller: {
96
+ createManualSession,
97
+ executeManualCommand,
98
+ parseManualCommand
99
+ },
100
+ showIntro,
101
+ updateNotice
102
+ });
103
+ return;
104
+ }
105
+
106
+ const lineReader = createManualLineReader({
107
+ input,
108
+ output
109
+ });
110
+ const session = createManualSession();
111
+ const context = {
112
+ cwd,
113
+ workflows,
114
+ prompts,
115
+ print(message = '') {
116
+ output.write(`${message}\n`);
117
+ },
118
+ readLine(prompt = '') {
119
+ return lineReader.readLine(prompt);
120
+ }
121
+ };
122
+
123
+ try {
124
+ if (showIntro) {
125
+ context.print('Logwork terminal UI. Type /help for commands.');
126
+ if (updateNotice) {
127
+ context.print(updateNotice);
128
+ }
129
+ }
130
+
131
+ while (true) {
132
+ let line;
133
+ try {
134
+ line = await context.readLine('logwork> ');
135
+ } catch (error) {
136
+ if (error.code === 'ERR_USE_AFTER_CLOSE' || /closed|end of input|EOF/i.test(error.message)) {
137
+ break;
138
+ }
139
+ throw error;
140
+ }
141
+ let command;
142
+ try {
143
+ command = parseManualCommand(line);
144
+ const result = await executeManualCommand(command, session, context);
145
+ if (result.exit) {
146
+ break;
147
+ }
148
+ } catch (error) {
149
+ context.print(`Error: ${error.message}`);
150
+ }
151
+ }
152
+ } finally {
153
+ lineReader.close();
154
+ }
155
+ }
156
+
157
+ export function parseManualCommand(line) {
158
+ const input = String(line || '').trim();
159
+ if (!input) {
160
+ return { type: 'noop' };
161
+ }
162
+
163
+ if (!input.startsWith('/')) {
164
+ throw new Error(`Unknown command: ${input}. Type /help for commands.`);
165
+ }
166
+
167
+ const [rawCommand, ...parts] = input.split(/\s+/);
168
+ const commandInfo = findManualCommand(rawCommand);
169
+ const command = commandInfo?.name;
170
+
171
+ if (command === '/help') {
172
+ return { type: 'help' };
173
+ }
174
+
175
+ if (command === '/auth') {
176
+ return parseTargetCommand('auth', parts, { allowBoth: false });
177
+ }
178
+
179
+ if (command === '/status') {
180
+ return parseTargetCommand('status', parts, { allowBoth: false });
181
+ }
182
+
183
+ if (command === '/diagnostics') {
184
+ return { type: 'diagnostics' };
185
+ }
186
+
187
+ if (command === '/history') {
188
+ return parseTargetCommand('history', parts, { allowBoth: true });
189
+ }
190
+
191
+ if (command === '/projects') {
192
+ return {
193
+ type: 'projects',
194
+ project: parts.join(' ').trim() || undefined
195
+ };
196
+ }
197
+
198
+ if (command === '/logwork') {
199
+ return parseLogworkCommand(parts);
200
+ }
201
+
202
+ if (command === '/edit-logwork') {
203
+ const options = parseRoEditArgs(parts);
204
+ if (options.yes || options.json) {
205
+ throw new Error('/edit-logwork always previews and confirms interactively; --yes and --json are not supported here.');
206
+ }
207
+ return {
208
+ type: 'edit_ro_logwork',
209
+ logworkId: options.logworkId,
210
+ hours: options.hours,
211
+ taskName: options.taskName,
212
+ description: options.description,
213
+ typeOfWork: options.typeOfWork
214
+ };
215
+ }
216
+
217
+ if (command === '/mcp') {
218
+ const clientName = parts.join(' ').trim();
219
+ if (!clientName) {
220
+ return {
221
+ type: 'mcp',
222
+ client: undefined
223
+ };
224
+ }
225
+ const client = resolveMcpClient(clientName);
226
+ if (!client) {
227
+ throw new Error('Unknown MCP client. Use: /mcp cursor | antigravity | copilot | claude-code | codex');
228
+ }
229
+ return {
230
+ type: 'mcp',
231
+ client: client.key
232
+ };
233
+ }
234
+
235
+ if (command === '/apply') {
236
+ return parseTargetCommand('apply', parts, { allowBoth: true });
237
+ }
238
+
239
+ if (command === '/query') {
240
+ return {
241
+ type: 'query',
242
+ args: parseQueryArgs(parts.join(' ').trim())
243
+ };
244
+ }
245
+
246
+ if (command === '/reconcile') {
247
+ if (parts.length > 1) {
248
+ throw new Error('Usage: /reconcile today | yesterday | this-week | last-week | this-month | last-month');
249
+ }
250
+ return {
251
+ type: 'reconcile',
252
+ period: parts[0] ? normalizeReconciliationPeriod(parts[0]) : undefined
253
+ };
254
+ }
255
+
256
+ if (command === '/map') {
257
+ return parseMapCommand(parts);
258
+ }
259
+
260
+ throw new Error(`Unknown command: ${rawCommand}. Type /help for commands.`);
261
+ }
262
+
263
+ export async function executeManualCommand(command, session, context) {
264
+ const {
265
+ cwd = process.cwd(),
266
+ workflows = defaultWorkflows(),
267
+ prompts = createManualPrompts(),
268
+ print = console.log,
269
+ readLine
270
+ } = context;
271
+
272
+ if (command.type === 'noop') {
273
+ return { exit: false };
274
+ }
275
+
276
+ if (command.type === 'help') {
277
+ print(MANUAL_HELP);
278
+ return { exit: false };
279
+ }
280
+
281
+ if (command.type === 'auth') {
282
+ const target = command.target || await readAuthTarget({ readLine, print });
283
+ if (target === JIRA_TARGET) {
284
+ print(jiraLoginInstruction());
285
+ return { exit: false };
286
+ }
287
+ const result = await workflows.loginResourceOptimiser();
288
+ print(result.summary);
289
+ return { exit: false };
290
+ }
291
+
292
+ if (command.type === 'status') {
293
+ if (command.target === RO_TARGET) {
294
+ const result = await workflows.getStoredAuthStatus();
295
+ print(result.summary);
296
+ return { exit: false };
297
+ }
298
+ if (command.target === JIRA_TARGET) {
299
+ const result = await workflows.getStoredJiraStatus();
300
+ print(result.summary);
301
+ return { exit: false };
302
+ }
303
+
304
+ const ro = await workflows.getStoredAuthStatus();
305
+ const jira = await workflows.getStoredJiraStatus();
306
+ print([
307
+ 'Auth status:',
308
+ `- Resource Optimiser: ${ro.summary}`,
309
+ `- Jira: ${jira.summary}`
310
+ ].join('\n'));
311
+ return { exit: false };
312
+ }
313
+
314
+ if (command.type === 'diagnostics') {
315
+ const result = await workflows.generateDiagnosticsReport({ cwd });
316
+ print(result.summary);
317
+ print('Send this sanitized file to the developer. Do not send raw curl logs, cookies, passwords, OTPs, or tokens.');
318
+ return { exit: false };
319
+ }
320
+
321
+ if (command.type === 'history') {
322
+ const result = await workflows.queryApplyLedger({
323
+ target: command.target,
324
+ cwd,
325
+ limit: 20
326
+ });
327
+ print(result.summary);
328
+ return { exit: false };
329
+ }
330
+
331
+ if (command.type === 'query') {
332
+ const result = await runWithInlineAuth(() => workflows.queryLogwork({
333
+ ...command.args,
334
+ cwd,
335
+ includeEntries: true
336
+ }), { workflows, print });
337
+ print(result.summary);
338
+ return { exit: false };
339
+ }
340
+
341
+ if (command.type === 'reconcile') {
342
+ const result = await runWithJiraAuthInstructions(() => runWithInlineAuth(() => workflows.reconcileLogwork({
343
+ period: command.period || 'this_week',
344
+ cwd
345
+ }), { workflows, print }));
346
+ print(result.summary);
347
+ return { exit: false };
348
+ }
349
+
350
+ if (command.type === 'projects') {
351
+ const result = await runWithInlineAuth(async () => {
352
+ const projectsResult = await workflows.listLogworkProjects({ cwd });
353
+ const weekly = await workflows.queryLogwork({
354
+ period: 'this_week',
355
+ project: command.project,
356
+ cwd,
357
+ includeEntries: false
358
+ });
359
+ return { projectsResult, weekly };
360
+ }, { workflows, print });
361
+ print(formatProjects(result.projectsResult, result.weekly, command.project));
362
+ return { exit: false };
363
+ }
364
+
365
+ if (command.type === 'map') {
366
+ const ok = await prompts.confirm(`Map ${command.tickets.join(', ')} to projectMemberId ${command.projectMemberId}?`);
367
+ if (!ok) {
368
+ print('Mapping cancelled.');
369
+ return { exit: false };
370
+ }
371
+
372
+ const result = await runWithInlineAuth(() => workflows.upsertProjectMapping({
373
+ projectMemberId: command.projectMemberId,
374
+ tickets: command.tickets,
375
+ keywords: [],
376
+ scope: 'user',
377
+ confirm: true,
378
+ cwd
379
+ }), { workflows, print });
380
+ print(result.summary);
381
+ return { exit: false };
382
+ }
383
+
384
+ if (command.type === 'logwork') {
385
+ const target = command.target || (command.text ? RO_TARGET : await readLogworkTarget({ readLine, print }));
386
+ const text = command.text || await readPasteBlock({ readLine, print, target });
387
+ const previews = await previewManualText({
388
+ target,
389
+ text,
390
+ projectOverrides: command.projectOverrides || {},
391
+ cwd,
392
+ workflows,
393
+ print
394
+ });
395
+ setSessionPreviews(session, target, previews);
396
+ print(formatManualPreviewSummaries(target, previews));
397
+ return { exit: false };
398
+ }
399
+
400
+ if (command.type === 'edit_ro_logwork') {
401
+ const editPreview = await runWithInlineAuth(() => workflows.previewRoLogworkEdit({
402
+ logworkId: command.logworkId,
403
+ hours: command.hours,
404
+ taskName: command.taskName,
405
+ description: command.description,
406
+ typeOfWork: command.typeOfWork
407
+ }), { workflows, print });
408
+ print(editPreview.summary);
409
+ const confirmed = await prompts.confirm(`Apply RO logwork edit ${editPreview.previewId}?`, false);
410
+ if (!confirmed) {
411
+ print('RO logwork edit cancelled.');
412
+ return { exit: false };
413
+ }
414
+ const result = await runWithInlineAuth(() => workflows.applyRoLogworkEdit({
415
+ preview: editPreview,
416
+ confirm: true,
417
+ cwd
418
+ }), { workflows, print });
419
+ print(result.summary);
420
+ return { exit: false };
421
+ }
422
+
423
+ if (command.type === 'mcp') {
424
+ const client = command.client || await readMcpClient({ readLine, print });
425
+ print(formatMcpSetup({ client }));
426
+ return { exit: false };
427
+ }
428
+
429
+ if (command.type === 'apply') {
430
+ await applyManualTarget({
431
+ target: command.target || session.activeTarget || RO_TARGET,
432
+ session,
433
+ cwd,
434
+ workflows,
435
+ prompts,
436
+ print
437
+ });
438
+ return { exit: false };
439
+ }
440
+
441
+ throw new Error(`Unsupported command type: ${command.type}`);
442
+ }
443
+
444
+ async function readMcpClient({ readLine, print }) {
445
+ if (typeof readLine !== 'function') {
446
+ throw new Error('/mcp requires an interactive terminal or a client name, for example: /mcp cursor');
447
+ }
448
+
449
+ print(formatMcpClientOptions());
450
+ while (true) {
451
+ const answer = await readLine('mcp client> ');
452
+ const client = resolveMcpClientSelection(answer);
453
+ if (client) {
454
+ return client.key;
455
+ }
456
+ print('Unknown MCP client. Choose a number or type: cursor, antigravity, copilot, claude-code, codex.');
457
+ }
458
+ }
459
+
460
+ async function readLogworkTarget({ readLine, print }) {
461
+ if (typeof readLine !== 'function') {
462
+ throw new Error('/logwork requires a target when not using the interactive picker: /logwork ro | jira | both');
463
+ }
464
+
465
+ print('Choose logwork target: ro, jira, or both.');
466
+ while (true) {
467
+ const target = parseTargetToken(await readLine('target> '));
468
+ if (target) {
469
+ return target;
470
+ }
471
+ print('Unknown target. Use: ro, jira, or both.');
472
+ }
473
+ }
474
+
475
+ async function readAuthTarget({ readLine, print }) {
476
+ if (typeof readLine !== 'function') {
477
+ throw new Error('/auth requires a target when not using the interactive picker: /auth ro | jira');
478
+ }
479
+
480
+ print('Choose auth target: ro or jira.');
481
+ while (true) {
482
+ const target = parseTargetToken(await readLine('auth target> '), { allowBoth: false });
483
+ if (target) {
484
+ return target;
485
+ }
486
+ print('Unknown target. Use: ro or jira.');
487
+ }
488
+ }
489
+
490
+ export function createManualPrompts() {
491
+ return {
492
+ async confirm(message, initialValue = true) {
493
+ const answer = await confirm({
494
+ message,
495
+ initialValue
496
+ });
497
+ if (isCancel(answer)) {
498
+ throw new Error('Cancelled.');
499
+ }
500
+ return Boolean(answer);
501
+ }
502
+ };
503
+ }
504
+
505
+ async function readPasteBlock({ readLine, print, target = RO_TARGET }) {
506
+ if (typeof readLine !== 'function') {
507
+ throw new Error('/logwork requires an interactive terminal.');
508
+ }
509
+
510
+ print(`Paste ${targetLabel(target)} weekly logwork text. Finish with /end on its own line.`);
511
+ const lines = [];
512
+ while (true) {
513
+ const line = await readLine('');
514
+ if (line.trim() === '/end') {
515
+ break;
516
+ }
517
+ lines.push(line);
518
+ }
519
+
520
+ const text = lines.join('\n').trim();
521
+ if (!text) {
522
+ throw new Error('Preview text is empty.');
523
+ }
524
+ return text;
525
+ }
526
+
527
+ async function previewManualText({
528
+ target,
529
+ text,
530
+ projectOverrides = {},
531
+ cwd,
532
+ workflows,
533
+ print
534
+ }) {
535
+ const previews = {};
536
+ if (previewTargets(target).includes(RO_TARGET)) {
537
+ previews[RO_TARGET] = await runWithInlineAuth(() => workflows.previewLogworkBatch({
538
+ text,
539
+ projectOverrides,
540
+ cwd
541
+ }), { workflows, print });
542
+ }
543
+ if (previewTargets(target).includes(JIRA_TARGET)) {
544
+ previews[JIRA_TARGET] = await runWithJiraAuthInstructions(() => workflows.previewJiraWorklogBatch({
545
+ text
546
+ }));
547
+ }
548
+ if (target === BOTH_TARGET && previews[RO_TARGET] && previews[JIRA_TARGET]) {
549
+ previews[RO_TARGET] = enrichRoPreviewWithJiraProjects(previews[RO_TARGET], previews[JIRA_TARGET]);
550
+ }
551
+ return previews;
552
+ }
553
+
554
+ function setSessionPreviews(session, target, previews) {
555
+ session.activeTarget = target;
556
+ session.previews = {
557
+ ...(session.previews || {}),
558
+ ...previews
559
+ };
560
+ }
561
+
562
+ function getSessionPreview(session, target) {
563
+ return session.previews?.[target] || null;
564
+ }
565
+
566
+ function clearSessionPreview(session, target) {
567
+ if (session.previews) {
568
+ session.previews[target] = null;
569
+ }
570
+ }
571
+
572
+ function clearSessionDraftIfNoPreviews(session) {
573
+ if (!session.previews?.[RO_TARGET] && !session.previews?.[JIRA_TARGET]) {
574
+ session.logworkDraft = null;
575
+ }
576
+ }
577
+
578
+ function formatManualPreviewSummaries(target, previews) {
579
+ return previewTargets(target)
580
+ .map((previewTarget) => {
581
+ const preview = previews[previewTarget];
582
+ return [
583
+ `${targetLabel(previewTarget)} preview:`,
584
+ preview?.summary || 'No preview returned.'
585
+ ].join('\n');
586
+ })
587
+ .join('\n\n');
588
+ }
589
+
590
+ async function applyManualTarget({
591
+ target,
592
+ flowTarget = target,
593
+ session,
594
+ cwd,
595
+ workflows,
596
+ prompts,
597
+ print
598
+ }) {
599
+ if (target === BOTH_TARGET) {
600
+ let appliedAny = false;
601
+ for (const previewTarget of [RO_TARGET, JIRA_TARGET]) {
602
+ const applied = await applyManualTarget({
603
+ target: previewTarget,
604
+ flowTarget: BOTH_TARGET,
605
+ session,
606
+ cwd,
607
+ workflows,
608
+ prompts,
609
+ print
610
+ });
611
+ appliedAny = appliedAny || applied;
612
+ }
613
+ if (!appliedAny && !getSessionPreview(session, RO_TARGET) && !getSessionPreview(session, JIRA_TARGET)) {
614
+ print('No preview available. Run /logwork first.');
615
+ }
616
+ return appliedAny;
617
+ }
618
+
619
+ if (target === JIRA_TARGET) {
620
+ return applyManualJiraPreview({
621
+ flowTarget,
622
+ session,
623
+ workflows,
624
+ prompts,
625
+ print
626
+ });
627
+ }
628
+
629
+ return applyManualRoPreview({
630
+ flowTarget,
631
+ session,
632
+ cwd,
633
+ workflows,
634
+ prompts,
635
+ print
636
+ });
637
+ }
638
+
639
+ async function applyManualRoPreview({
640
+ flowTarget,
641
+ session,
642
+ cwd,
643
+ workflows,
644
+ prompts,
645
+ print
646
+ }) {
647
+ const preview = getSessionPreview(session, RO_TARGET);
648
+ const blocker = getManualApplyBlocker(preview);
649
+ if (blocker) {
650
+ print(blocker);
651
+ return false;
652
+ }
653
+
654
+ const confirmed = await prompts.confirm(buildManualApplyConfirmation(preview));
655
+ if (!confirmed) {
656
+ print('Resource Optimiser apply cancelled.');
657
+ return false;
658
+ }
659
+
660
+ let allowUnbooked = false;
661
+ if (manualUnbookedEntryCount(preview)) {
662
+ allowUnbooked = await prompts.confirm(buildManualUnbookedConfirmation(preview));
663
+ if (!allowUnbooked) {
664
+ print('Resource Optimiser apply cancelled because unbooked entries were not approved.');
665
+ return false;
666
+ }
667
+ }
668
+
669
+ const result = await runWithInlineAuth(() => workflows.applyLogworkBatch({
670
+ batch: preview,
671
+ confirm: true,
672
+ allowUnbooked,
673
+ cwd,
674
+ ledgerContext: { flowTarget }
675
+ }), { workflows, print });
676
+ print(result.summary);
677
+ if (result.verification?.summary) {
678
+ print('');
679
+ print(result.verification.summary);
680
+ }
681
+ clearSessionPreview(session, RO_TARGET);
682
+ clearSessionDraftIfNoPreviews(session);
683
+ return true;
684
+ }
685
+
686
+ async function applyManualJiraPreview({
687
+ flowTarget,
688
+ session,
689
+ workflows,
690
+ prompts,
691
+ print
692
+ }) {
693
+ const preview = getSessionPreview(session, JIRA_TARGET);
694
+ const blocker = getManualJiraApplyBlocker(preview);
695
+ if (blocker) {
696
+ print(blocker);
697
+ return false;
698
+ }
699
+
700
+ const confirmed = await prompts.confirm(buildManualJiraApplyConfirmation(preview));
701
+ if (!confirmed) {
702
+ print('Jira apply cancelled.');
703
+ return false;
704
+ }
705
+
706
+ const result = await runWithJiraAuthInstructions(() => workflows.applyJiraWorklogBatch({
707
+ batch: preview,
708
+ confirm: true,
709
+ ledgerContext: { flowTarget }
710
+ }));
711
+ print(result.summary);
712
+ clearSessionPreview(session, JIRA_TARGET);
713
+ clearSessionDraftIfNoPreviews(session);
714
+ return true;
715
+ }
716
+
717
+ function getManualJiraApplyBlocker(preview) {
718
+ if (!preview) {
719
+ return 'No Jira preview available. Run /logwork jira first.';
720
+ }
721
+ if (preview.errors?.length) {
722
+ return 'Cannot apply Jira preview with parse errors.';
723
+ }
724
+ const blocked = (preview.entries || []).filter((entry) => entry.status !== 'ready');
725
+ if (blocked.length) {
726
+ return `Cannot apply Jira preview with ${blocked.length} blocked entries.`;
727
+ }
728
+ if (!(preview.entries || []).length) {
729
+ return 'Cannot apply Jira preview with no entries.';
730
+ }
731
+ return '';
732
+ }
733
+
734
+ function buildManualJiraApplyConfirmation(preview) {
735
+ const entries = preview.entries || [];
736
+ const hours = entries.reduce((sum, entry) => sum + Number(entry.hours || 0), 0);
737
+ const issues = [...new Set(entries.map((entry) => entry.issueKey).filter(Boolean))];
738
+ return `Apply Jira worklog preview with ${entries.length} entries, ${formatHours(hours)}h total, issues ${issues.join(', ') || 'unknown'}?`;
739
+ }
740
+
741
+ export async function previewManualLogworkDraft({
742
+ date,
743
+ project,
744
+ tasks,
745
+ cwd = process.cwd(),
746
+ workflows = defaultWorkflows(),
747
+ print = console.log
748
+ }) {
749
+ const text = buildLogworkText({ date, tasks });
750
+ const projectOverrides = buildProjectOverrides({
751
+ date,
752
+ tasks,
753
+ projectMemberId: project.projectMemberId
754
+ });
755
+ return runWithInlineAuth(() => workflows.previewLogworkBatch({
756
+ text,
757
+ projectOverrides,
758
+ cwd
759
+ }), { workflows, print });
760
+ }
761
+
762
+ export function addDraftTask({ draft, line }) {
763
+ const task = parseTaskLine(line);
764
+ return {
765
+ ...draft,
766
+ tasks: [...(draft.tasks || []), task]
767
+ };
768
+ }
769
+
770
+ export function removeDraftTask({ draft, index }) {
771
+ const taskIndex = Number(index) - 1;
772
+ if (!Number.isInteger(taskIndex) || taskIndex < 0 || taskIndex >= (draft.tasks || []).length) {
773
+ throw new Error(`No task found at index ${index}.`);
774
+ }
775
+ return {
776
+ ...draft,
777
+ tasks: draft.tasks.filter((_, currentIndex) => currentIndex !== taskIndex)
778
+ };
779
+ }
780
+
781
+ async function runWithInlineAuth(action, { workflows, print }) {
782
+ try {
783
+ return await action();
784
+ } catch (error) {
785
+ if (!isAuthRequiredError(error)) {
786
+ throw error;
787
+ }
788
+ print('Resource Optimiser authentication required. Starting terminal auth...');
789
+ const result = await workflows.loginResourceOptimiser();
790
+ print(result.summary);
791
+ return action();
792
+ }
793
+ }
794
+
795
+ async function runWithJiraAuthInstructions(action) {
796
+ try {
797
+ return await action();
798
+ } catch (error) {
799
+ if (!isJiraAuthRequiredError(error)) {
800
+ throw error;
801
+ }
802
+ throw new Error(jiraLoginInstruction());
803
+ }
804
+ }
805
+
806
+ function parseQueryArgs(value) {
807
+ if (!value || value === 'today') {
808
+ return { period: 'today' };
809
+ }
810
+
811
+ if (value === 'yesterday') {
812
+ return { period: 'yesterday' };
813
+ }
814
+
815
+ if (value === 'this-week' || value === 'this_week' || value === 'week') {
816
+ return { period: 'this_week' };
817
+ }
818
+
819
+ if (value === 'last-week' || value === 'last_week') {
820
+ return { period: 'last_week' };
821
+ }
822
+
823
+ if (value === 'this-month' || value === 'this_month' || value === 'month') {
824
+ return { period: 'this_month' };
825
+ }
826
+
827
+ if (value === 'last-month' || value === 'last_month') {
828
+ return { period: 'last_month' };
829
+ }
830
+
831
+ throw new Error('Usage: /query today | yesterday | this-week | last-week | this-month | last-month');
832
+ }
833
+
834
+ function parseLogworkCommand(parts) {
835
+ const first = parts[0];
836
+ const target = parseTargetToken(first);
837
+ if (!target) {
838
+ return {
839
+ type: 'logwork',
840
+ text: parts.join(' ').trim() || undefined
841
+ };
842
+ }
843
+
844
+ return {
845
+ type: 'logwork',
846
+ target,
847
+ text: parts.slice(1).join(' ').trim() || undefined
848
+ };
849
+ }
850
+
851
+ function parseTargetCommand(type, parts, {
852
+ allowBoth
853
+ } = {}) {
854
+ if (!parts.length) {
855
+ return {
856
+ type
857
+ };
858
+ }
859
+
860
+ const target = parseTargetToken(parts[0], { allowBoth });
861
+ if (!target) {
862
+ throw new Error(`Usage: /${type} ${allowBoth ? 'ro | jira | both' : 'ro | jira'}`);
863
+ }
864
+ if (parts.length > 1) {
865
+ throw new Error(`Usage: /${type} ${allowBoth ? 'ro | jira | both' : 'ro | jira'}`);
866
+ }
867
+
868
+ return {
869
+ type,
870
+ target
871
+ };
872
+ }
873
+
874
+ function parseMapCommand(parts) {
875
+ if (parts.length < 2) {
876
+ throw new Error('Usage: /map <TICKET_PREFIX[,PREFIX...]> <projectMemberId>');
877
+ }
878
+
879
+ const tickets = parts[0]
880
+ .split(',')
881
+ .map((ticket) => ticket.trim().toUpperCase())
882
+ .filter(Boolean);
883
+ const projectMemberId = parts[1];
884
+
885
+ if (!tickets.length || !projectMemberId) {
886
+ throw new Error('Usage: /map <TICKET_PREFIX[,PREFIX...]> <projectMemberId>');
887
+ }
888
+
889
+ return {
890
+ type: 'map',
891
+ tickets,
892
+ projectMemberId
893
+ };
894
+ }
895
+
896
+ export function formatProjects(result, weekly = null, projectFilter = undefined) {
897
+ const lines = [result.summary];
898
+ const projects = filterProjects(result.projects || [], projectFilter);
899
+ const chartedProjectKeys = new Set();
900
+ const chartBlocks = formatWeeklyProjectCharts(projects, weekly, projectFilter, chartedProjectKeys);
901
+
902
+ if (chartBlocks.length) {
903
+ lines.push('');
904
+ lines.push('This week:');
905
+ lines.push(...chartBlocks);
906
+ } else if (projectFilter) {
907
+ lines.push('');
908
+ lines.push(`No weekly booking/logwork chart found for "${projectFilter}".`);
909
+ }
910
+
911
+ const otherProjects = projects.filter((project) => !chartedProjectKeys.has(projectIdentityKey(project)));
912
+ if (otherProjects.length) {
913
+ lines.push('');
914
+ lines.push(chartBlocks.length ? 'Other memberships:' : 'Projects:');
915
+ for (const project of otherProjects) {
916
+ lines.push(`- ${project.projectMemberId}: ${project.projectName} (projectId: ${project.projectId ?? 'unknown'})`);
917
+ }
918
+ }
919
+
920
+ if (result.mappings?.length) {
921
+ lines.push('');
922
+ lines.push('Mappings:');
923
+ for (const mapping of result.mappings) {
924
+ lines.push(`- ${mapping.tickets.join(', ')} -> ${mapping.projectName}${mapping.projectMemberId ? ` (${mapping.projectMemberId})` : ''}`);
925
+ }
926
+ }
927
+
928
+ return lines.join('\n');
929
+ }
930
+
931
+ export function formatWeeklyProjectCharts(projects, weekly, projectFilter, chartedProjectKeys = new Set()) {
932
+ if (!weekly?.projects?.length) {
933
+ return [];
934
+ }
935
+
936
+ const lines = [];
937
+ const weeklyProjects = weekly.projects.filter((project) => (
938
+ !projectFilter || projectMatchesFilter(project, projectFilter)
939
+ ));
940
+
941
+ for (const weeklyProject of weeklyProjects) {
942
+ const membership = projects.find((project) => sameProjectIdentity(project, weeklyProject)) || weeklyProject;
943
+ const key = projectIdentityKey(membership);
944
+ chartedProjectKeys.add(key);
945
+ if (lines.length) {
946
+ lines.push('');
947
+ }
948
+ lines.push(...formatProjectChart({
949
+ project: {
950
+ ...membership,
951
+ bookedHours: weeklyProject.bookedHours,
952
+ loggedHours: weeklyProject.loggedHours
953
+ },
954
+ days: projectDays(weekly?.days || [], weeklyProject)
955
+ }));
956
+ }
957
+
958
+ return lines;
959
+ }
960
+
961
+ export function formatProjectChart({ project, days = [], width = 20 }) {
962
+ const bookedHours = Number(project.bookedHours || 0);
963
+ const loggedHours = Number(project.loggedHours || 0);
964
+ const ratio = bookedHours > 0 ? loggedHours / bookedHours : (loggedHours > 0 ? 1 : 0);
965
+ const filled = Math.max(0, Math.min(width, Math.round(Math.min(ratio, 1) * width)));
966
+ const percent = bookedHours > 0 ? `${Math.round(ratio * 100)}%` : (loggedHours > 0 ? 'unbooked' : '0%');
967
+ const over = bookedHours > 0 && loggedHours > bookedHours
968
+ ? ` (+${formatHours(loggedHours - bookedHours)}h over)`
969
+ : '';
970
+
971
+ return [
972
+ project.projectName,
973
+ `memberId: ${project.projectMemberId ?? 'unknown'} · projectId: ${project.projectId ?? 'unknown'}`,
974
+ `This week: ${formatHours(loggedHours)}h logged / ${formatHours(bookedHours)}h booked${over}`,
975
+ `[${'█'.repeat(filled)}${'░'.repeat(width - filled)}] ${percent}`,
976
+ formatDayChips(days)
977
+ ];
978
+ }
979
+
980
+ function projectDays(days, project) {
981
+ return days
982
+ .map((day) => {
983
+ const matching = (day.projects || []).find((candidate) => sameProjectIdentity(candidate, project));
984
+ if (!matching) {
985
+ return null;
986
+ }
987
+ return {
988
+ date: day.date,
989
+ bookedHours: matching.bookedHours,
990
+ loggedHours: matching.loggedHours
991
+ };
992
+ })
993
+ .filter(Boolean);
994
+ }
995
+
996
+ function formatDayChips(days) {
997
+ if (!days.length) {
998
+ return 'No booking/logwork found this week.';
999
+ }
1000
+
1001
+ return days.map((day) => (
1002
+ `${weekdayName(day.date)} ${formatHours(day.loggedHours)}/${formatHours(day.bookedHours)}`
1003
+ )).join(' ');
1004
+ }
1005
+
1006
+ function weekdayName(localDateISO) {
1007
+ const [year, month, day] = String(localDateISO).split('-').map(Number);
1008
+ const date = new Date(year, month - 1, day);
1009
+ return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][date.getDay()] || localDateISO;
1010
+ }
1011
+
1012
+ function filterProjects(projects, projectFilter) {
1013
+ if (!projectFilter) {
1014
+ return projects;
1015
+ }
1016
+ return projects.filter((project) => projectMatchesFilter(project, projectFilter));
1017
+ }
1018
+
1019
+ function defaultWorkflows() {
1020
+ return {
1021
+ queryLogwork,
1022
+ reconcileLogwork,
1023
+ previewLogworkBatch,
1024
+ applyLogworkBatch,
1025
+ previewRoLogworkEdit,
1026
+ applyRoLogworkEdit,
1027
+ previewJiraWorklogBatch,
1028
+ applyJiraWorklogBatch,
1029
+ listLogworkProjects,
1030
+ upsertProjectMapping,
1031
+ loginResourceOptimiser,
1032
+ getStoredAuthStatus,
1033
+ getStoredJiraStatus,
1034
+ queryApplyLedger,
1035
+ generateDiagnosticsReport
1036
+ };
1037
+ }