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,842 @@
1
+ import {
2
+ addLogtime,
3
+ ApiError,
4
+ fetchWorklogTasksForProject,
5
+ getBookedProjectsForDate,
6
+ getProjects
7
+ } from './api.mjs';
8
+ import {
9
+ listAvailableTaskNames,
10
+ listSuggestedTaskNames,
11
+ resolveWorklogTask
12
+ } from './worklog-task-resolver.mjs';
13
+ import { recordApplyResult, recordApplyResultSafely } from './apply-ledger.mjs';
14
+ import { parseWeeklyLogText } from './batch-parser.mjs';
15
+ import { loadLocalConfig } from './logwork-config.mjs';
16
+ import { buildSetupSuggestions } from './project-mapping-workflow.mjs';
17
+ import { resolveEntryProject } from './project-resolver.mjs';
18
+ import { createResourceOptimiserSession, queryLogwork } from './query-workflow.mjs';
19
+
20
+ export async function previewLogworkBatch({
21
+ text,
22
+ projectOverrides = {},
23
+ cwd = process.cwd(),
24
+ fetchProjects,
25
+ fetchMembershipProjects,
26
+ fetchWorklogTasks,
27
+ typeOfWork,
28
+ defaultWorklogTask
29
+ }) {
30
+ const config = await loadLocalConfig(cwd);
31
+ const parsed = parseWeeklyLogText(text);
32
+ const dates = [...new Set(parsed.entries.map((entry) => entry.date))];
33
+ const projectsByDate = new Map();
34
+ const projectFetcher = fetchProjects || createBookedProjectsFetcher();
35
+ const membershipFetcher = fetchMembershipProjects || createMembershipProjectsFetcher();
36
+ const worklogTaskFetcher = fetchWorklogTasks || createWorklogTasksFetcher();
37
+
38
+ for (const date of dates) {
39
+ projectsByDate.set(date, await projectFetcher(date));
40
+ }
41
+
42
+ const bookedPreview = buildLogworkBatchPreview({
43
+ parsed,
44
+ projectsByDate,
45
+ projectOverrides,
46
+ config
47
+ });
48
+
49
+ const preview = !bookedPreview.unresolvedEntries.length
50
+ ? bookedPreview
51
+ : buildLogworkBatchPreview({
52
+ parsed,
53
+ projectsByDate,
54
+ membershipProjects: await membershipFetcher(),
55
+ projectOverrides,
56
+ config
57
+ });
58
+
59
+ const normalizedDefaultTask = String(defaultWorklogTask ?? '').trim();
60
+ const enriched = await attachWorklogTaskTargets(preview, {
61
+ fetchWorklogTasks: worklogTaskFetcher,
62
+ defaultWorklogTask: normalizedDefaultTask
63
+ });
64
+ const output = normalizedDefaultTask
65
+ ? {
66
+ ...enriched,
67
+ // The default task changes what gets written, so approval must not reuse
68
+ // a batchId previewed with a different default.
69
+ batchId: `${enriched.batchId}_${hash(`defaultWorklogTask:${normalizedDefaultTask}`)}`,
70
+ defaultWorklogTask: normalizedDefaultTask
71
+ }
72
+ : enriched;
73
+ return typeOfWork ? { ...output, typeOfWork } : output;
74
+ }
75
+
76
+ export const LOGWORK_LINE_FORMAT = '+<hours> <worklog task> | <description>';
77
+
78
+ export async function listWorklogTasks({ projectId, fetchWorklogTasks } = {}) {
79
+ const normalizedProjectId = Number(projectId);
80
+ if (!Number.isFinite(normalizedProjectId) || normalizedProjectId <= 0) {
81
+ throw new Error('projectId must be a positive Resource Optimiser project id (see list_projects).');
82
+ }
83
+ const fetcher = fetchWorklogTasks || createWorklogTasksFetcher();
84
+ const tasks = (await fetcher(normalizedProjectId)).map((task) => ({
85
+ id: task.id,
86
+ name: String(task.name || '').trim(),
87
+ typeOfWork: task.typeOfWork ?? task.type_of_work ?? null,
88
+ description: String(task.description || '').trim(),
89
+ projectSpecific: task.projectId !== null && task.projectId !== undefined,
90
+ isDefault: Boolean(task.isDefault ?? task.is_default)
91
+ })).filter((task) => task.name);
92
+
93
+ return {
94
+ projectId: normalizedProjectId,
95
+ tasks,
96
+ lineFormat: LOGWORK_LINE_FORMAT,
97
+ summary: [
98
+ `Found ${tasks.length} worklog task${tasks.length === 1 ? '' : 's'} for project ${normalizedProjectId}.`,
99
+ ...tasks.map((task) => `- #${task.id} ${task.name}${task.typeOfWork ? ` (${task.typeOfWork})` : ''}${task.description ? `: ${task.description}` : ''}`),
100
+ `Write logwork lines as "${LOGWORK_LINE_FORMAT}", for example "+2 Feature Development | Implement maintenance mode UI (SCB-213)". Pick the task whose description best matches the work.`
101
+ ].join('\n')
102
+ };
103
+ }
104
+
105
+ // Resolves which Resource Optimiser worklog task (catalog item) and which free-text
106
+ // description each entry writes:
107
+ // - "+2 Code Review & Refactoring | Review PR #12 (SCB-1)": explicit task + description.
108
+ // - "+2 Code Review": whole text matches a catalog task, so no description.
109
+ // - "+2 Review PR #12 (SCB-1)": no catalog match, so the text becomes the
110
+ // description and defaultWorklogTask supplies the task; without a default the
111
+ // entry stays unresolved with reason worklog_task_required.
112
+ export async function attachWorklogTaskTargets(preview, { fetchWorklogTasks, defaultWorklogTask = '' } = {}) {
113
+ if (!fetchWorklogTasks) {
114
+ return preview;
115
+ }
116
+
117
+ const taskCache = new Map();
118
+ const entries = [];
119
+
120
+ for (const entry of preview.entries || []) {
121
+ if (entry.status !== 'resolved' && entry.status !== 'resolved_unbooked') {
122
+ entries.push(entry);
123
+ continue;
124
+ }
125
+
126
+ const projectId = entry.matchedProject?.projectId;
127
+ let tasks = [];
128
+ try {
129
+ tasks = await fetchWorklogTasks(projectId, taskCache);
130
+ const { resolution, description, reason } = resolveEntryWorklogTask({
131
+ entry,
132
+ projectId,
133
+ tasks,
134
+ defaultWorklogTask
135
+ });
136
+ entries.push({
137
+ ...entry,
138
+ taskName: resolution.taskName || entry.taskName,
139
+ description,
140
+ descriptionMissing: !description,
141
+ worklogTaskId: resolution.worklogTaskId,
142
+ worklogTaskName: resolution.taskName || entry.taskName,
143
+ worklogNeedsProjectId: resolution.needsProjectId,
144
+ ...(resolution.typeOfWork ? { typeOfWork: resolution.typeOfWork } : {}),
145
+ taskResolution: {
146
+ status: 'resolved',
147
+ reason,
148
+ worklogTaskId: resolution.worklogTaskId,
149
+ worklogTaskName: resolution.taskName || entry.taskName,
150
+ ...(resolution.typeOfWork ? { typeOfWork: resolution.typeOfWork } : {})
151
+ }
152
+ });
153
+ } catch (error) {
154
+ const classified = classifyWorklogTaskError(error);
155
+ const suggestedTaskNames = classified.reason === 'task_not_found'
156
+ ? listSuggestedTaskNames(tasks, entry.taskName)
157
+ : [];
158
+ const availableTaskNames = classified.reason === 'task_lookup_failed'
159
+ ? []
160
+ : listAvailableTaskNames(tasks);
161
+ entries.push({
162
+ ...entry,
163
+ status: 'unresolved',
164
+ reason: classified.reason,
165
+ confidence: 0,
166
+ worklogTaskId: undefined,
167
+ worklogNeedsProjectId: undefined,
168
+ suggestedTaskNames,
169
+ availableTaskNames,
170
+ taskResolution: {
171
+ status: 'unresolved',
172
+ reason: classified.reason,
173
+ error: classified.message,
174
+ suggestedTaskNames,
175
+ availableTaskNames
176
+ },
177
+ resolution: {
178
+ ...(entry.resolution || {}),
179
+ status: 'unresolved',
180
+ reason: classified.reason,
181
+ confidence: 0,
182
+ autoResolved: false,
183
+ requiresUserChoice: true,
184
+ requiresAllowUnbooked: false,
185
+ candidates: entry.resolution?.candidates || []
186
+ }
187
+ });
188
+ }
189
+ }
190
+
191
+ const unresolvedEntries = entries.filter((entry) => entry.status === 'unresolved');
192
+ const unbookedEntries = entries.filter((entry) => entry.status === 'resolved_unbooked');
193
+ const approvalSummary = buildApprovalSummary(entries, preview.errors || []);
194
+ const setupSuggestions = preview.setupSuggestions || [];
195
+
196
+ return {
197
+ ...preview,
198
+ entries,
199
+ unresolvedEntries,
200
+ unbookedEntries,
201
+ approvalSummary,
202
+ status: getBatchStatus(preview.errors || [], unresolvedEntries, unbookedEntries),
203
+ summary: formatPreviewSummary(entries, preview.errors || [], setupSuggestions, approvalSummary)
204
+ };
205
+ }
206
+
207
+ function resolveEntryWorklogTask({ entry, projectId, tasks, defaultWorklogTask }) {
208
+ if (entry.taskExplicit) {
209
+ return {
210
+ resolution: resolveWorklogTask({ taskName: entry.taskName, projectId, tasks }),
211
+ description: entry.description || '',
212
+ reason: 'worklog_task_match'
213
+ };
214
+ }
215
+
216
+ try {
217
+ return {
218
+ resolution: resolveWorklogTask({ taskName: entry.taskName, projectId, tasks }),
219
+ description: '',
220
+ reason: 'worklog_task_match'
221
+ };
222
+ } catch (error) {
223
+ if (!String(error?.message || '').startsWith('No worklog task named')) {
224
+ throw error;
225
+ }
226
+ }
227
+
228
+ const text = entry.text ?? entry.taskName;
229
+ if (!defaultWorklogTask) {
230
+ throw new WorklogTaskRequiredError(
231
+ `"${text}" is not a Resource Optimiser worklog task for project ${projectId}. ` +
232
+ 'Write the line as "+<hours> <worklog task> | <description>" or pass defaultWorklogTask.'
233
+ );
234
+ }
235
+ return {
236
+ resolution: resolveWorklogTask({ taskName: defaultWorklogTask, projectId, tasks }),
237
+ description: text,
238
+ reason: 'default_worklog_task'
239
+ };
240
+ }
241
+
242
+ class WorklogTaskRequiredError extends Error {
243
+ constructor(message) {
244
+ super(message);
245
+ this.code = 'worklog_task_required';
246
+ }
247
+ }
248
+
249
+ export function buildLogworkBatchPreview({
250
+ parsed,
251
+ projectsByDate,
252
+ membershipProjects = [],
253
+ projectOverrides = {},
254
+ config = {}
255
+ }) {
256
+ const batchId = createBatchId(parsed.entries, projectOverrides);
257
+ const entries = parsed.entries.map((entry) => {
258
+ const projects = projectsByDate instanceof Map
259
+ ? projectsByDate.get(entry.date) || []
260
+ : projectsByDate?.[entry.date] || [];
261
+ const resolution = resolveEntryProject({
262
+ entry,
263
+ projects,
264
+ membershipProjects,
265
+ projectOverrides,
266
+ config
267
+ });
268
+
269
+ const matchedProject = resolution.status === 'resolved' || resolution.status === 'resolved_unbooked' ? {
270
+ projectMemberId: resolution.project.projectMemberId,
271
+ projectId: resolution.project.projectId,
272
+ projectName: resolution.project.projectName
273
+ } : null;
274
+
275
+ return {
276
+ ...entry,
277
+ status: resolution.status,
278
+ reason: resolution.reason,
279
+ confidence: resolution.confidence,
280
+ matchedProject,
281
+ booked: Boolean(resolution.booked),
282
+ requiresAllowUnbooked: Boolean(resolution.requiresAllowUnbooked),
283
+ candidates: resolution.candidates,
284
+ resolution: buildResolutionMetadata(resolution)
285
+ };
286
+ });
287
+ const unresolvedEntries = entries.filter((entry) => entry.status === 'unresolved');
288
+ const unbookedEntries = entries.filter((entry) => entry.status === 'resolved_unbooked');
289
+ const setupSuggestions = buildSetupSuggestions(unresolvedEntries, membershipProjects);
290
+ const approvalSummary = buildApprovalSummary(entries, parsed.errors);
291
+
292
+ return {
293
+ batchId,
294
+ status: getBatchStatus(parsed.errors, unresolvedEntries, unbookedEntries),
295
+ errors: parsed.errors,
296
+ entries,
297
+ unresolvedEntries,
298
+ unbookedEntries,
299
+ setupSuggestions,
300
+ totalsByDate: totalsBy(entries, (entry) => entry.date),
301
+ totalsByProject: totalsBy(entries.filter((entry) => entry.matchedProject), (entry) => entry.matchedProject.projectName),
302
+ approvalSummary,
303
+ summary: formatPreviewSummary(entries, parsed.errors, setupSuggestions, approvalSummary)
304
+ };
305
+ }
306
+
307
+ export async function applyLogworkBatch({
308
+ batch,
309
+ confirm,
310
+ projectOverrides = {},
311
+ allowUnbooked = false,
312
+ cwd = process.cwd(),
313
+ fetchProjects,
314
+ fetchMembershipProjects,
315
+ submitEntry,
316
+ verifyLogwork,
317
+ recordLedger,
318
+ ledgerContext = {}
319
+ }) {
320
+ if (confirm !== true) {
321
+ throw new Error('Apply requires confirm: true.');
322
+ }
323
+
324
+ const preview = batch?.entries
325
+ ? applyOverridesToBatch(batch, projectOverrides)
326
+ : await previewLogworkBatch({
327
+ text: batch?.text,
328
+ projectOverrides,
329
+ cwd,
330
+ fetchProjects,
331
+ fetchMembershipProjects
332
+ });
333
+
334
+ if (preview.errors?.length) {
335
+ throw new Error(`Cannot apply batch with parse errors: ${preview.errors.map((error) => error.message).join('; ')}`);
336
+ }
337
+
338
+ if (preview.unresolvedEntries?.length) {
339
+ throw new Error(`Cannot apply batch with ${preview.unresolvedEntries.length} unresolved entries.`);
340
+ }
341
+
342
+ const unbookedEntries = preview.entries.filter((entry) => entry.requiresAllowUnbooked);
343
+ if (unbookedEntries.length && allowUnbooked !== true) {
344
+ const blocked = unbookedEntries
345
+ .map((entry) => `${entry.id}: +${entry.hours}h ${entry.taskName} -> ${entry.matchedProject?.projectName || 'unknown project'}`)
346
+ .join('; ');
347
+ throw new Error(`Cannot apply ${unbookedEntries.length} unbooked entries without allowUnbooked: true. ${blocked}`);
348
+ }
349
+
350
+ const results = [];
351
+ const taskCache = new Map();
352
+ const entrySubmitter = submitEntry || createLogtimeSubmitter(taskCache);
353
+ const ledgerWriter = recordLedger === undefined
354
+ ? (!submitEntry && !verifyLogwork ? recordApplyResult : null)
355
+ : recordLedger;
356
+ for (const entry of preview.entries) {
357
+ try {
358
+ const entryTypeOfWork = preview.typeOfWork || entry.typeOfWork;
359
+ const result = await entrySubmitter({
360
+ projectMemberId: entry.matchedProject.projectMemberId,
361
+ projectId: entry.matchedProject.projectId,
362
+ logtimes: entry.hours,
363
+ taskName: entry.taskName,
364
+ ...(entry.description ? { description: entry.description } : {}),
365
+ localDateISO: entry.date,
366
+ worklogTaskId: entry.worklogTaskId,
367
+ worklogNeedsProjectId: entry.worklogNeedsProjectId,
368
+ ...(entryTypeOfWork ? { typeOfWork: entryTypeOfWork } : {}),
369
+ taskCache
370
+ });
371
+
372
+ results.push({
373
+ entryId: entry.id,
374
+ date: entry.date,
375
+ hours: entry.hours,
376
+ taskName: entry.taskName,
377
+ description: entry.description || '',
378
+ projectName: entry.matchedProject.projectName,
379
+ status: 'approved',
380
+ dryRun: Boolean(result?.dryRun),
381
+ ok: true,
382
+ result
383
+ });
384
+ } catch (error) {
385
+ results.push({
386
+ entryId: entry.id,
387
+ date: entry.date,
388
+ hours: entry.hours,
389
+ taskName: entry.taskName,
390
+ description: entry.description || '',
391
+ projectName: entry.matchedProject.projectName,
392
+ status: 'failed',
393
+ dryRun: false,
394
+ ok: false,
395
+ error: error.message
396
+ });
397
+ error.applyLedger = await recordRoApplyLedger({
398
+ writer: ledgerWriter,
399
+ preview,
400
+ results,
401
+ status: results.some((result) => result.status === 'approved') ? 'partial_failed' : 'failed',
402
+ cwd,
403
+ ledgerContext
404
+ });
405
+ throw error;
406
+ }
407
+ }
408
+
409
+ const output = {
410
+ batchId: preview.batchId,
411
+ status: 'submitted',
412
+ dryRun: results.some((result) => result.dryRun),
413
+ results,
414
+ submissionSummary: buildSubmissionSummary(results),
415
+ verification: await buildApplyVerification({
416
+ preview,
417
+ results,
418
+ cwd,
419
+ verifyLogwork
420
+ }),
421
+ summary: formatApplySummary(results)
422
+ };
423
+ output.ledger = await recordRoApplyLedger({
424
+ writer: ledgerWriter,
425
+ preview,
426
+ results,
427
+ status: output.status,
428
+ cwd,
429
+ ledgerContext
430
+ });
431
+ return output;
432
+ }
433
+
434
+ export function formatToolResponse(payload) {
435
+ return {
436
+ content: [
437
+ {
438
+ type: 'text',
439
+ text: `${payload.summary}\n\n${JSON.stringify(payload, null, 2)}`
440
+ }
441
+ ],
442
+ structuredContent: payload
443
+ };
444
+ }
445
+
446
+ function createBookedProjectsFetcher() {
447
+ const session = createResourceOptimiserSession();
448
+ return async (localDateISO) => {
449
+ const { token, userId } = await session.get();
450
+ try {
451
+ return await getBookedProjectsForDate(token, userId, localDateISO);
452
+ } catch (error) {
453
+ if (!(error instanceof ApiError) || error.status !== 401) {
454
+ throw error;
455
+ }
456
+
457
+ const refreshed = await session.get({ forceLogin: true });
458
+ return getBookedProjectsForDate(refreshed.token, refreshed.userId, localDateISO);
459
+ }
460
+ };
461
+ }
462
+
463
+ function createMembershipProjectsFetcher() {
464
+ const session = createResourceOptimiserSession();
465
+ return async () => {
466
+ const { token, userId } = await session.get();
467
+ try {
468
+ return await getProjects(token, userId);
469
+ } catch (error) {
470
+ if (!(error instanceof ApiError) || error.status !== 401) {
471
+ throw error;
472
+ }
473
+
474
+ const refreshed = await session.get({ forceLogin: true });
475
+ return getProjects(refreshed.token, refreshed.userId);
476
+ }
477
+ };
478
+ }
479
+
480
+ function createLogtimeSubmitter(taskCache = new Map()) {
481
+ const session = createResourceOptimiserSession();
482
+ return async (entry) => {
483
+ const payload = {
484
+ ...entry,
485
+ taskCache: entry.taskCache || taskCache
486
+ };
487
+ const { token } = await session.get();
488
+ try {
489
+ return await addLogtime(token, payload);
490
+ } catch (error) {
491
+ if (!(error instanceof ApiError) || error.status !== 401) {
492
+ throw error;
493
+ }
494
+
495
+ const refreshed = await session.get({ forceLogin: true });
496
+ return addLogtime(refreshed.token, payload);
497
+ }
498
+ };
499
+ }
500
+
501
+ function createWorklogTasksFetcher() {
502
+ const session = createResourceOptimiserSession();
503
+ const cache = new Map();
504
+ return async (projectId, taskCache = cache) => {
505
+ const { token } = await session.get();
506
+ try {
507
+ return await fetchWorklogTasksForProject(token, projectId, { cache: taskCache });
508
+ } catch (error) {
509
+ if (!(error instanceof ApiError) || error.status !== 401) {
510
+ throw error;
511
+ }
512
+
513
+ const refreshed = await session.get({ forceLogin: true });
514
+ return fetchWorklogTasksForProject(refreshed.token, projectId, { cache: taskCache });
515
+ }
516
+ };
517
+ }
518
+
519
+ function applyOverridesToBatch(batch, projectOverrides) {
520
+ if (!projectOverrides || !Object.keys(projectOverrides).length) {
521
+ return batch;
522
+ }
523
+
524
+ const entries = (batch.entries || []).map((entry) => {
525
+ if (!Object.hasOwn(projectOverrides, entry.id)) {
526
+ return entry;
527
+ }
528
+
529
+ const candidates = (entry.candidates || []).map((candidate) => ({
530
+ projectMemberId: candidate.projectMemberId,
531
+ projectId: candidate.projectId,
532
+ projectName: candidate.projectName,
533
+ booked: candidate.booked
534
+ }));
535
+ if (entry.matchedProject) {
536
+ candidates.push({
537
+ ...entry.matchedProject,
538
+ booked: Boolean(entry.booked)
539
+ });
540
+ }
541
+
542
+ const preview = buildLogworkBatchPreview({
543
+ parsed: {
544
+ entries: [entry],
545
+ errors: []
546
+ },
547
+ projectsByDate: new Map([[entry.date, candidates.filter((candidate) => candidate.booked)]]),
548
+ membershipProjects: candidates,
549
+ projectOverrides,
550
+ config: { projectMappings: [] }
551
+ });
552
+ return preview.entries[0];
553
+ });
554
+
555
+ const unresolvedEntries = entries.filter((entry) => entry.status === 'unresolved');
556
+ const unbookedEntries = entries.filter((entry) => entry.status === 'resolved_unbooked');
557
+ const setupSuggestions = buildSetupSuggestions(unresolvedEntries, entries.flatMap((entry) => entry.candidates || []));
558
+ const approvalSummary = buildApprovalSummary(entries, batch.errors || []);
559
+ return {
560
+ ...batch,
561
+ status: getBatchStatus(batch.errors || [], unresolvedEntries, unbookedEntries),
562
+ entries,
563
+ unresolvedEntries,
564
+ unbookedEntries,
565
+ setupSuggestions,
566
+ totalsByDate: totalsBy(entries, (entry) => entry.date),
567
+ totalsByProject: totalsBy(entries.filter((entry) => entry.matchedProject), (entry) => entry.matchedProject.projectName),
568
+ approvalSummary,
569
+ summary: formatPreviewSummary(entries, batch.errors || [], setupSuggestions, approvalSummary)
570
+ };
571
+ }
572
+
573
+ async function buildApplyVerification({ preview, results, cwd, verifyLogwork }) {
574
+ if (results.some((result) => result.dryRun)) {
575
+ return null;
576
+ }
577
+
578
+ const dates = [...new Set(preview.entries.map((entry) => entry.date))].sort();
579
+ if (!dates.length) {
580
+ return null;
581
+ }
582
+
583
+ const from = dates[0];
584
+ const to = addOneDay(dates[dates.length - 1]);
585
+ const verifier = verifyLogwork || ((args) => queryLogwork({
586
+ ...args,
587
+ cwd,
588
+ includeEntries: false
589
+ }));
590
+
591
+ return verifier({ from, to, includeEntries: false });
592
+ }
593
+
594
+ function addOneDay(localDateISO) {
595
+ const [year, month, day] = localDateISO.split('-').map(Number);
596
+ const date = new Date(year, month - 1, day + 1);
597
+ return [
598
+ String(date.getFullYear()).padStart(4, '0'),
599
+ String(date.getMonth() + 1).padStart(2, '0'),
600
+ String(date.getDate()).padStart(2, '0')
601
+ ].join('-');
602
+ }
603
+
604
+ function createBatchId(entries, projectOverrides) {
605
+ const source = JSON.stringify({
606
+ entries: entries.map((entry) => [entry.id, entry.date, entry.hours, entry.text ?? entry.taskName]),
607
+ projectOverrides
608
+ });
609
+ return `batch_${hash(source)}`;
610
+ }
611
+
612
+ function getBatchStatus(errors, unresolvedEntries, unbookedEntries) {
613
+ if (errors.length) {
614
+ return 'invalid';
615
+ }
616
+
617
+ if (unresolvedEntries.length) {
618
+ return 'unresolved';
619
+ }
620
+
621
+ if (unbookedEntries.length) {
622
+ return 'ready_with_unbooked';
623
+ }
624
+
625
+ return 'ready';
626
+ }
627
+
628
+ function hash(value) {
629
+ let hashValue = 2166136261;
630
+ for (let index = 0; index < value.length; index += 1) {
631
+ hashValue ^= value.charCodeAt(index);
632
+ hashValue = Math.imul(hashValue, 16777619);
633
+ }
634
+ return (hashValue >>> 0).toString(16);
635
+ }
636
+
637
+ function totalsBy(entries, keyFn) {
638
+ return entries.reduce((totals, entry) => {
639
+ const key = keyFn(entry);
640
+ totals[key] = Number((Number(totals[key] || 0) + Number(entry.hours || 0)).toFixed(2));
641
+ return totals;
642
+ }, {});
643
+ }
644
+
645
+ function buildApprovalSummary(entries, errors = []) {
646
+ const unresolvedEntries = entries.filter((entry) => entry.status === 'unresolved');
647
+ const unbookedEntries = entries.filter((entry) => entry.status === 'resolved_unbooked');
648
+ const dates = [...new Set(entries.map((entry) => entry.date).filter(Boolean))].sort();
649
+ const projectMap = new Map();
650
+
651
+ for (const entry of entries) {
652
+ const key = entry.matchedProject?.projectMemberId
653
+ ? `member:${entry.matchedProject.projectMemberId}`
654
+ : entry.matchedProject?.projectName
655
+ ? `name:${entry.matchedProject.projectName}`
656
+ : 'unresolved';
657
+ const existing = projectMap.get(key) || {
658
+ projectMemberId: entry.matchedProject?.projectMemberId ?? null,
659
+ projectId: entry.matchedProject?.projectId ?? null,
660
+ projectName: entry.matchedProject?.projectName || 'UNRESOLVED',
661
+ hours: 0,
662
+ entries: 0,
663
+ unbookedEntries: 0,
664
+ unresolvedEntries: 0
665
+ };
666
+
667
+ existing.hours = Number((existing.hours + Number(entry.hours || 0)).toFixed(2));
668
+ existing.entries += 1;
669
+ if (entry.status === 'resolved_unbooked') {
670
+ existing.unbookedEntries += 1;
671
+ }
672
+ if (entry.status === 'unresolved') {
673
+ existing.unresolvedEntries += 1;
674
+ }
675
+ projectMap.set(key, existing);
676
+ }
677
+
678
+ return {
679
+ entryCount: entries.length,
680
+ totalHours: Number(entries.reduce((total, entry) => total + Number(entry.hours || 0), 0).toFixed(2)),
681
+ dateRange: dates.length ? { from: dates[0], to: dates[dates.length - 1] } : null,
682
+ projectCount: [...projectMap.values()].filter((project) => project.projectName !== 'UNRESOLVED').length,
683
+ projects: [...projectMap.values()],
684
+ unresolvedEntries: unresolvedEntries.length,
685
+ unbookedEntries: unbookedEntries.length,
686
+ requiresUserChoice: unresolvedEntries.length > 0,
687
+ requiresAllowUnbooked: unbookedEntries.length > 0,
688
+ confirmRequired: true,
689
+ canApply: !errors.length && unresolvedEntries.length === 0
690
+ };
691
+ }
692
+
693
+ function buildResolutionMetadata(resolution) {
694
+ const autoResolved = resolution.status === 'resolved' || resolution.status === 'resolved_unbooked';
695
+ return {
696
+ status: resolution.status,
697
+ reason: resolution.reason,
698
+ confidence: resolution.confidence,
699
+ booked: Boolean(resolution.booked),
700
+ autoResolved,
701
+ requiresUserChoice: resolution.status === 'unresolved',
702
+ requiresAllowUnbooked: Boolean(resolution.requiresAllowUnbooked),
703
+ candidates: resolution.candidates || []
704
+ };
705
+ }
706
+
707
+ function formatPreviewSummary(entries, errors, setupSuggestions = [], approvalSummary = buildApprovalSummary(entries, errors)) {
708
+ if (errors.length) {
709
+ return [
710
+ 'Logwork preview has parse errors:',
711
+ ...errors.map((error) => `- line ${error.line}: ${error.message}`)
712
+ ].join('\n');
713
+ }
714
+
715
+ const lines = ['Logwork preview:'];
716
+ for (const entry of entries) {
717
+ const project = entry.matchedProject?.projectName || 'UNRESOLVED';
718
+ const prefix = entry.status === 'resolved_unbooked' ? 'UNBOOKED: ' : '';
719
+ const hint = formatResolutionHint(entry);
720
+ lines.push(`- ${entry.date}: ${prefix}+${entry.hours}h ${project} - ${formatTaskAndDescription(entry)}${hint ? ` (${hint})` : ''}`);
721
+ }
722
+ lines.push(...formatApprovalSummaryLines(approvalSummary));
723
+ const unresolved = entries.filter((entry) => entry.status === 'unresolved').length;
724
+ const unbooked = entries.filter((entry) => entry.status === 'resolved_unbooked').length;
725
+ if (unresolved) {
726
+ lines.push(`Unresolved entries: ${unresolved}`);
727
+ const taskFailures = entries.filter((entry) => entry.reason === 'task_not_found').length;
728
+ if (taskFailures) {
729
+ lines.push('Some entries need a worklog task name that exactly matches Resource Optimiser.');
730
+ }
731
+ const taskRequired = entries.filter((entry) => entry.reason === 'worklog_task_required');
732
+ if (taskRequired.length) {
733
+ const names = taskRequired[0].availableTaskNames || [];
734
+ lines.push('Some entries need a worklog task. Use "+<hours> <worklog task> | <description>" or pass defaultWorklogTask, then preview again.');
735
+ if (names.length) {
736
+ lines.push(`Available worklog tasks: ${names.join(', ')}.`);
737
+ }
738
+ }
739
+ if (setupSuggestions.length) {
740
+ lines.push('Choose a project and call map_project, then preview again.');
741
+ }
742
+ } else if (unbooked) {
743
+ lines.push(`Unbooked entries: ${unbooked}. Apply requires allowUnbooked: true.`);
744
+ } else {
745
+ lines.push('Ready to apply.');
746
+ }
747
+ const missingDescriptions = entries.filter((entry) => entry.status !== 'unresolved' && entry.descriptionMissing).length;
748
+ if (missingDescriptions) {
749
+ lines.push(`Warning: ${missingDescriptions} entr${missingDescriptions === 1 ? 'y has' : 'ies have'} no description. Use "+<hours> <worklog task> | <description>" to add one.`);
750
+ }
751
+ return lines.join('\n');
752
+ }
753
+
754
+ function formatTaskAndDescription(entry) {
755
+ const description = entry.description || '';
756
+ return description ? `${entry.taskName} | ${description}` : entry.taskName;
757
+ }
758
+
759
+ function formatApprovalSummaryLines(approvalSummary) {
760
+ if (!approvalSummary.entryCount) {
761
+ return [];
762
+ }
763
+
764
+ const dateText = approvalSummary.dateRange
765
+ ? `${approvalSummary.dateRange.from}${approvalSummary.dateRange.to === approvalSummary.dateRange.from ? '' : ` to ${approvalSummary.dateRange.to}`}`
766
+ : 'unknown dates';
767
+ const projects = approvalSummary.projects
768
+ .map((project) => `${project.projectName}: ${formatHours(project.hours)}h`)
769
+ .join('; ');
770
+
771
+ return [
772
+ `Approval checklist: ${approvalSummary.entryCount} entr${approvalSummary.entryCount === 1 ? 'y' : 'ies'}, ${formatHours(approvalSummary.totalHours)}h total, ${dateText}.`,
773
+ `Projects: ${projects || 'none'}.`
774
+ ];
775
+ }
776
+
777
+ function formatResolutionHint(entry) {
778
+ const reason = entry.reason || entry.resolution?.reason;
779
+ if (!reason) {
780
+ return '';
781
+ }
782
+
783
+ const confidence = Number(entry.confidence ?? entry.resolution?.confidence);
784
+ const confidenceText = Number.isFinite(confidence)
785
+ ? `, confidence ${formatConfidence(confidence)}`
786
+ : '';
787
+ const prefix = entry.status === 'unresolved' ? 'needs user choice' : 'via';
788
+ return `${prefix} ${reason}${confidenceText}`;
789
+ }
790
+
791
+ function formatConfidence(confidence) {
792
+ return Number(confidence).toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
793
+ }
794
+
795
+ function formatApplySummary(results) {
796
+ const submissionSummary = buildSubmissionSummary(results);
797
+ return [
798
+ results.some((result) => result.dryRun) ? 'DRY RUN: logwork payloads generated.' : 'Logwork submitted:',
799
+ `Submitted ${submissionSummary.entryCount} entr${submissionSummary.entryCount === 1 ? 'y' : 'ies'}, ${formatHours(submissionSummary.totalHours)}h total.`,
800
+ ...results.map((result) => `- ${result.date}: +${result.hours}h ${result.projectName} - ${formatTaskAndDescription(result)}`)
801
+ ].join('\n');
802
+ }
803
+
804
+ function buildSubmissionSummary(results) {
805
+ return {
806
+ entryCount: results.length,
807
+ totalHours: Number(results.reduce((total, result) => total + Number(result.hours || 0), 0).toFixed(2)),
808
+ dryRun: results.some((result) => result.dryRun),
809
+ dates: [...new Set(results.map((result) => result.date).filter(Boolean))].sort(),
810
+ projects: Object.entries(totalsBy(results, (result) => result.projectName || 'Unknown project'))
811
+ .map(([projectName, hours]) => ({ projectName, hours }))
812
+ };
813
+ }
814
+
815
+ function recordRoApplyLedger({ writer, preview, results, status, cwd, ledgerContext }) {
816
+ return recordApplyResultSafely(writer, {
817
+ target: 'ro',
818
+ flowTarget: ledgerContext.flowTarget || 'ro',
819
+ batchId: preview.batchId,
820
+ status,
821
+ cwd,
822
+ entries: results
823
+ });
824
+ }
825
+
826
+ function formatHours(hours) {
827
+ return Number(hours).toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
828
+ }
829
+
830
+ function classifyWorklogTaskError(error) {
831
+ const message = String(error?.message || error || 'Task lookup failed.');
832
+ if (error?.code === 'worklog_task_required') {
833
+ return { reason: 'worklog_task_required', message };
834
+ }
835
+ if (
836
+ message.startsWith('No worklog task named')
837
+ || message.startsWith('Ambiguous worklog task')
838
+ ) {
839
+ return { reason: 'task_not_found', message };
840
+ }
841
+ return { reason: 'task_lookup_failed', message };
842
+ }