logwork-cli 1.1.0 → 1.2.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 (70) hide show
  1. package/README.md +35 -9
  2. package/RELEASE.md +1 -0
  3. package/commands/commits.mjs +57 -6
  4. package/commands/history.mjs +0 -4
  5. package/commands/quick.mjs +38 -23
  6. package/commands/reminder.mjs +41 -2
  7. package/commands/setup.mjs +157 -19
  8. package/commands/tui.mjs +1 -0
  9. package/docs/advanced.md +12 -2
  10. package/docs/mcp-setup.md +57 -15
  11. package/docs/operations-guide.md +37 -2
  12. package/docs/recipes.md +27 -0
  13. package/docs/security.md +3 -1
  14. package/examples/mcp/antigravity.mcp_config.json +1 -1
  15. package/examples/mcp/codex.config.toml +1 -1
  16. package/examples/mcp/copilot.mcp.json +1 -1
  17. package/lib/api-auth-forms.mjs +393 -0
  18. package/lib/api-auth-http.mjs +184 -0
  19. package/lib/api-auth-support.mjs +101 -0
  20. package/lib/api-auth-tokens.mjs +129 -0
  21. package/lib/api-auth.mjs +39 -813
  22. package/lib/api.mjs +38 -1422
  23. package/lib/apply-ledger.mjs +1 -1
  24. package/lib/batch-parser.mjs +23 -0
  25. package/lib/batch-workflow.mjs +201 -2
  26. package/lib/commit-workflow.mjs +278 -30
  27. package/lib/config.mjs +7 -0
  28. package/lib/diagnostics.mjs +53 -4
  29. package/lib/jira-api.mjs +25 -3
  30. package/lib/jira-workflow.mjs +36 -6
  31. package/lib/keychain.mjs +19 -12
  32. package/lib/logwork-config.mjs +64 -13
  33. package/lib/manual-apply-state.mjs +15 -0
  34. package/lib/manual-commands.mjs +2 -2
  35. package/lib/manual-ink-app.mjs +108 -1281
  36. package/lib/manual-ink-draft-actions.mjs +227 -0
  37. package/lib/manual-ink-interaction.mjs +306 -0
  38. package/lib/manual-ink-session.mjs +41 -0
  39. package/lib/manual-ink-task-editor.mjs +338 -0
  40. package/lib/manual-ink-wizard.mjs +520 -0
  41. package/lib/manual-mcp.mjs +27 -15
  42. package/lib/manual-repl.mjs +39 -3
  43. package/lib/mcp-server.mjs +63 -21
  44. package/lib/node-runtime.mjs +115 -0
  45. package/lib/npm-command.mjs +29 -0
  46. package/lib/paths.mjs +11 -1
  47. package/lib/project-mapping-workflow.mjs +30 -7
  48. package/lib/query-workflow.mjs +0 -1
  49. package/lib/reconciliation-workflow.mjs +1 -1
  50. package/lib/release-doctor.mjs +4 -2
  51. package/lib/reminder-config.mjs +38 -2
  52. package/lib/reminder-scheduler.mjs +54 -3
  53. package/lib/reminder-service.mjs +28 -3
  54. package/lib/reminder-workflow.mjs +118 -22
  55. package/lib/ro-api-diagnostics.mjs +109 -0
  56. package/lib/ro-api-entries.mjs +359 -0
  57. package/lib/ro-api-fields.mjs +290 -0
  58. package/lib/ro-api-http.mjs +68 -0
  59. package/lib/ro-api-projects.mjs +137 -0
  60. package/lib/ro-api-tasks.mjs +50 -0
  61. package/lib/ro-api-timesheet.mjs +413 -0
  62. package/lib/ro-delete-workflow.mjs +5 -8
  63. package/lib/ro-edit-workflow.mjs +66 -8
  64. package/lib/ro-entry-reader.mjs +49 -6
  65. package/lib/ro-resubmit-workflow.mjs +35 -21
  66. package/lib/terminal-auth-prompts.mjs +7 -4
  67. package/lib/ui.mjs +80 -59
  68. package/lib/update-service.mjs +5 -2
  69. package/package.json +11 -4
  70. package/skills/logwork/SKILL.md +6 -4
@@ -211,7 +211,7 @@ function createRecordId(date, random) {
211
211
 
212
212
  function cleanText(value, maxLength) {
213
213
  const text = String(value || '')
214
- .replace(/Bearer\s+[A-Za-z0-9._~+\/-]+/gi, 'Bearer <redacted>')
214
+ .replace(/Bearer\s+[A-Za-z0-9._~+/-]+/gi, 'Bearer <redacted>')
215
215
  .replace(/(token|password|secret|otp)\s*[=:]\s*[^\s,;]+/gi, '$1=<redacted>')
216
216
  .trim();
217
217
  return text ? text.slice(0, maxLength) : '';
@@ -1,6 +1,8 @@
1
1
  const DAY_HEADING_RE = /^(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday),\s+(\d{2})\s+([A-Za-z]{3})\s+(\d{4})$/;
2
2
  const ENTRY_RE = /^\+(\d+(?:\.\d+)?)\s+(.+)$/;
3
3
  const TICKET_RE = /\b[A-Z][A-Z0-9]+-\d+\b/g;
4
+ const MAX_DAY_HOURS = 24;
5
+ const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
4
6
 
5
7
  const MONTHS = new Map([
6
8
  ['jan', 1],
@@ -67,6 +69,18 @@ export function parseWeeklyLogText(text) {
67
69
  });
68
70
  continue;
69
71
  }
72
+ // RO rejects more than 24h; catching it here keeps a batch from failing
73
+ // halfway after earlier entries were written.
74
+ const dayTotal = entries.filter((item) => item.date === currentDate).reduce((sum, item) => sum + item.hours, 0) + hours;
75
+ if (hours > MAX_DAY_HOURS || dayTotal > MAX_DAY_HOURS) {
76
+ errors.push({
77
+ line: lineNumber,
78
+ message: hours > MAX_DAY_HOURS
79
+ ? `Hours ${entry[1]} exceed ${MAX_DAY_HOURS}h for one entry.`
80
+ : `Entries for ${currentDate} add up to ${dayTotal}h, more than ${MAX_DAY_HOURS}h in one day.`
81
+ });
82
+ continue;
83
+ }
70
84
 
71
85
  const text = entry[2].trim();
72
86
  const { taskName, description, taskExplicit } = splitTaskAndDescription(text);
@@ -137,6 +151,15 @@ function parseHeadingDate(match, lineNumber, errors) {
137
151
  return null;
138
152
  }
139
153
 
154
+ const weekday = WEEKDAYS[date.getUTCDay()];
155
+ if (weekday !== match[1]) {
156
+ errors.push({
157
+ line: lineNumber,
158
+ message: `Date heading says ${match[1]} but ${match[2]} ${match[3]} ${match[4]} is a ${weekday}: ${match[0]}`
159
+ });
160
+ return null;
161
+ }
162
+
140
163
  return [
141
164
  String(year).padStart(4, '0'),
142
165
  String(month).padStart(2, '0'),
@@ -3,8 +3,10 @@ import {
3
3
  ApiError,
4
4
  fetchWorklogTasksForProject,
5
5
  getBookedProjectsForDate,
6
+ getLogworkDay,
6
7
  getProjects
7
8
  } from './api.mjs';
9
+ import { isAuthRequiredError } from './auth-errors.mjs';
8
10
  import {
9
11
  listAvailableTaskNames,
10
12
  listSuggestedTaskNames,
@@ -24,6 +26,7 @@ export async function previewLogworkBatch({
24
26
  fetchProjects,
25
27
  fetchMembershipProjects,
26
28
  fetchWorklogTasks,
29
+ fetchDayEntries,
27
30
  typeOfWork,
28
31
  defaultWorklogTask
29
32
  }) {
@@ -70,7 +73,155 @@ export async function previewLogworkBatch({
70
73
  defaultWorklogTask: normalizedDefaultTask
71
74
  }
72
75
  : enriched;
73
- return typeOfWork ? { ...output, typeOfWork } : output;
76
+ // typeOfWork also changes what gets written, so it is folded into the
77
+ // batchId the same way.
78
+ const typed = typeOfWork
79
+ ? { ...output, typeOfWork, batchId: `${output.batchId}_${hash(`typeOfWork:${typeOfWork}`)}`, summary: `${output.summary}\n\nType of work for every entry: ${typeOfWork}` }
80
+ : output;
81
+ return attachDuplicateWarnings(typed, {
82
+ fetchDayEntries: fetchDayEntries || createDayEntriesFetcher()
83
+ });
84
+ }
85
+
86
+ // Duplicate guard: an entry is a possible duplicate when RO already has an
87
+ // entry on the same date with the same worklog task (id, else name), the same
88
+ // hours and the same description (trimmed, whitespace collapsed, any case), or
89
+ // when an earlier line of this batch is identical. Apply refuses those entries
90
+ // unless allowDuplicates: true. A failed day read (other than auth) skips the
91
+ // check for that date and says so; it never blocks the preview.
92
+ export async function attachDuplicateWarnings(preview, { fetchDayEntries } = {}) {
93
+ const entries = preview.entries || [];
94
+ if (!entries.length || preview.errors?.length) {
95
+ return preview;
96
+ }
97
+
98
+ const dates = [...new Set(entries.map((entry) => entry.date).filter(Boolean))].sort();
99
+ const existingByDate = new Map();
100
+ const skipped = [];
101
+ for (const date of dates) {
102
+ try {
103
+ existingByDate.set(date, await fetchDayEntries(date));
104
+ } catch (error) {
105
+ if (isAuthRequiredError(error) || (error instanceof ApiError && error.status === 401)) {
106
+ throw error;
107
+ }
108
+ skipped.push({ date, error: String(error?.message || error) });
109
+ }
110
+ }
111
+
112
+ const seen = [];
113
+ const flagged = entries.map((entry) => {
114
+ const { possibleDuplicate: _previous, ...base } = entry;
115
+ const existing = (existingByDate.get(entry.date) || [])
116
+ .filter((candidate) => !candidate?.raw?.deleted_date && !candidate?.deletedDate)
117
+ .find((candidate) => isSameLogwork(entry, candidate));
118
+ const earlier = seen.find((candidate) => isSameLogwork(entry, candidate));
119
+ seen.push(entry);
120
+ if (existing) {
121
+ return {
122
+ ...base,
123
+ possibleDuplicate: {
124
+ source: 'ro',
125
+ logworkId: existing.id ?? null,
126
+ date: existing.date || entry.date,
127
+ hours: Number(existing.hours),
128
+ taskName: existingTaskName(existing),
129
+ worklogTaskId: existing.worklogTaskId ?? null,
130
+ description: existing.description || '',
131
+ status: existing.status || '',
132
+ projectName: existing.projectName || ''
133
+ }
134
+ };
135
+ }
136
+ if (earlier) {
137
+ return {
138
+ ...base,
139
+ possibleDuplicate: {
140
+ source: 'batch',
141
+ logworkId: null,
142
+ entryId: earlier.id,
143
+ date: earlier.date,
144
+ hours: earlier.hours,
145
+ taskName: earlier.taskName,
146
+ description: earlier.description || ''
147
+ }
148
+ };
149
+ }
150
+ return base;
151
+ });
152
+
153
+ const duplicateEntries = flagged.filter((entry) => entry.possibleDuplicate);
154
+ const duplicateCheck = {
155
+ status: skipped.length ? (skipped.length === dates.length ? 'skipped' : 'partial') : 'checked',
156
+ checkedDates: dates.filter((date) => existingByDate.has(date)),
157
+ ...(skipped.length ? { skippedDates: skipped } : {})
158
+ };
159
+ const summaryLines = [];
160
+ if (duplicateEntries.length) {
161
+ summaryLines.push(`Possible duplicates: ${duplicateEntries.length}. Apply requires allowDuplicates: true.`);
162
+ summaryLines.push(...duplicateEntries.map(formatDuplicateWarning));
163
+ }
164
+ for (const item of skipped) {
165
+ summaryLines.push(`Note: duplicate check skipped for ${item.date} (could not read RO entries: ${item.error}).`);
166
+ }
167
+
168
+ return {
169
+ ...preview,
170
+ entries: flagged,
171
+ unresolvedEntries: flagged.filter((entry) => entry.status === 'unresolved'),
172
+ unbookedEntries: flagged.filter((entry) => entry.status === 'resolved_unbooked'),
173
+ duplicateEntries,
174
+ duplicateCheck,
175
+ approvalSummary: {
176
+ ...preview.approvalSummary,
177
+ possibleDuplicates: duplicateEntries.length,
178
+ requiresAllowDuplicates: duplicateEntries.length > 0
179
+ },
180
+ summary: summaryLines.length ? `${preview.summary}\n${summaryLines.join('\n')}` : preview.summary
181
+ };
182
+ }
183
+
184
+ function formatDuplicateWarning(entry) {
185
+ const duplicate = entry.possibleDuplicate;
186
+ const line = `${entry.id}: +${entry.hours}h ${formatTaskAndDescription(entry)}`;
187
+ if (duplicate.source === 'batch') {
188
+ return `- WARNING possible duplicate ${line} repeats line ${duplicate.entryId} of this batch.`;
189
+ }
190
+ const status = duplicate.status ? `, ${duplicate.status}` : '';
191
+ const project = duplicate.projectName ? ` on ${duplicate.projectName}` : '';
192
+ return `- WARNING possible duplicate ${line} matches existing RO logwork ${duplicate.logworkId ?? '(no id)'}${project}${status}.`;
193
+ }
194
+
195
+ function isSameLogwork(entry, other) {
196
+ if (!other || (other.date && entry.date && other.date !== entry.date)) {
197
+ return false;
198
+ }
199
+ if (Math.abs(Number(entry.hours) - Number(other.hours)) > 1e-9) {
200
+ return false;
201
+ }
202
+ if (normalizeDuplicateText(entry.description) !== normalizeDuplicateText(other.description)) {
203
+ return false;
204
+ }
205
+ const entryTaskId = entry.worklogTaskId;
206
+ const otherTaskId = other.worklogTaskId;
207
+ if (hasId(entryTaskId) && hasId(otherTaskId)) {
208
+ return String(entryTaskId) === String(otherTaskId);
209
+ }
210
+ const entryTask = normalizeDuplicateText(entry.worklogTaskName || entry.taskName);
211
+ return Boolean(entryTask) && entryTask === normalizeDuplicateText(existingTaskName(other));
212
+ }
213
+
214
+ function existingTaskName(entry) {
215
+ const raw = entry?.raw || {};
216
+ return entry?.worklogTaskName || raw.worklog_task_name || raw.worklog_task?.name || raw.worklogTask?.name || entry?.taskName || '';
217
+ }
218
+
219
+ function normalizeDuplicateText(value) {
220
+ return String(value ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
221
+ }
222
+
223
+ function hasId(value) {
224
+ return value !== undefined && value !== null && String(value).trim() !== '';
74
225
  }
75
226
 
76
227
  export const LOGWORK_LINE_FORMAT = '+<hours> <worklog task> | <description>';
@@ -309,9 +460,11 @@ export async function applyLogworkBatch({
309
460
  confirm,
310
461
  projectOverrides = {},
311
462
  allowUnbooked = false,
463
+ allowDuplicates = false,
312
464
  cwd = process.cwd(),
313
465
  fetchProjects,
314
466
  fetchMembershipProjects,
467
+ fetchDayEntries,
315
468
  submitEntry,
316
469
  verifyLogwork,
317
470
  recordLedger,
@@ -328,7 +481,8 @@ export async function applyLogworkBatch({
328
481
  projectOverrides,
329
482
  cwd,
330
483
  fetchProjects,
331
- fetchMembershipProjects
484
+ fetchMembershipProjects,
485
+ fetchDayEntries
332
486
  });
333
487
 
334
488
  if (preview.errors?.length) {
@@ -347,6 +501,11 @@ export async function applyLogworkBatch({
347
501
  throw new Error(`Cannot apply ${unbookedEntries.length} unbooked entries without allowUnbooked: true. ${blocked}`);
348
502
  }
349
503
 
504
+ const duplicateEntries = preview.entries.filter((entry) => entry.possibleDuplicate);
505
+ if (duplicateEntries.length && allowDuplicates !== true) {
506
+ throw new Error(`Nothing was written. ${formatDuplicateBlock(duplicateEntries)} Apply requires allowDuplicates: true.`);
507
+ }
508
+
350
509
  const results = [];
351
510
  const taskCache = new Map();
352
511
  const entrySubmitter = submitEntry || createLogtimeSubmitter(taskCache);
@@ -394,6 +553,16 @@ export async function applyLogworkBatch({
394
553
  ok: false,
395
554
  error: error.message
396
555
  });
556
+ // Earlier entries are already in RO. Say so plainly: re-applying the same
557
+ // text would log them twice.
558
+ const written = results.filter((result) => result.ok);
559
+ const notTried = preview.entries.slice(results.length).map((item) => item.id);
560
+ error.message = `Entry ${entry.id} (+${entry.hours}h ${entry.taskName}) failed: ${error.message}` +
561
+ (written.length
562
+ ? ` Already written to RO: ${written.map((result) => `${result.entryId} (+${result.hours}h ${result.taskName})`).join(', ')}. Do not apply this batch again; preview only the entries that were not written.`
563
+ : ' Nothing was written.') +
564
+ (notTried.length ? ` Not attempted: ${notTried.join(', ')}.` : '');
565
+ error.partialResults = results;
397
566
  error.applyLedger = await recordRoApplyLedger({
398
567
  writer: ledgerWriter,
399
568
  preview,
@@ -460,6 +629,36 @@ function createBookedProjectsFetcher() {
460
629
  };
461
630
  }
462
631
 
632
+ export function formatDuplicateBlock(entries) {
633
+ const list = entries.map((entry) => {
634
+ const duplicate = entry.possibleDuplicate || {};
635
+ const target = duplicate.source === 'batch'
636
+ ? `line ${duplicate.entryId}`
637
+ : `RO logwork ${duplicate.logworkId ?? '(no id)'}`;
638
+ return `${entry.id} (+${entry.hours}h ${entry.taskName}) matches ${target}`;
639
+ }).join('; ');
640
+ return `${entries.length} entr${entries.length === 1 ? 'y looks' : 'ies look'} already logged: ${list}.`;
641
+ }
642
+
643
+ // Existing RO entries for one date, from GET /logwork/day, with one forced
644
+ // re-login on a 401.
645
+ function createDayEntriesFetcher() {
646
+ const session = createResourceOptimiserSession();
647
+ return async (localDateISO) => {
648
+ const { token } = await session.get();
649
+ try {
650
+ return (await getLogworkDay(token, { date: localDateISO })).entries || [];
651
+ } catch (error) {
652
+ if (!(error instanceof ApiError) || error.status !== 401) {
653
+ throw error;
654
+ }
655
+
656
+ const refreshed = await session.get({ forceLogin: true });
657
+ return (await getLogworkDay(refreshed.token, { date: localDateISO })).entries || [];
658
+ }
659
+ };
660
+ }
661
+
463
662
  function createMembershipProjectsFetcher() {
464
663
  const session = createResourceOptimiserSession();
465
664
  return async () => {