logwork-cli 1.1.1 → 1.3.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 +53 -25
  2. package/RELEASE.md +4 -2
  3. package/bin/logwork.mjs +2 -2
  4. package/commands/commits.mjs +128 -13
  5. package/commands/history.mjs +0 -4
  6. package/commands/mcp.mjs +266 -0
  7. package/commands/quick.mjs +38 -23
  8. package/commands/reminder.mjs +41 -2
  9. package/commands/setup.mjs +197 -42
  10. package/commands/tui.mjs +1 -0
  11. package/docs/advanced.md +18 -7
  12. package/docs/mcp-setup.md +120 -26
  13. package/docs/operations-guide.md +50 -2
  14. package/docs/recipes.md +40 -0
  15. package/docs/security.md +23 -2
  16. package/examples/mcp/antigravity.mcp_config.json +2 -2
  17. package/examples/mcp/codex.config.toml +2 -2
  18. package/examples/mcp/copilot.mcp.json +2 -2
  19. package/lib/api-auth-forms.mjs +393 -0
  20. package/lib/api-auth-http.mjs +184 -0
  21. package/lib/api-auth-support.mjs +101 -0
  22. package/lib/api-auth-tokens.mjs +129 -0
  23. package/lib/api-auth.mjs +39 -813
  24. package/lib/api.mjs +38 -1422
  25. package/lib/apply-ledger.mjs +1 -1
  26. package/lib/batch-workflow.mjs +187 -2
  27. package/lib/commit-workflow.mjs +345 -23
  28. package/lib/config.mjs +7 -0
  29. package/lib/diagnostics.mjs +116 -4
  30. package/lib/jira-api.mjs +25 -3
  31. package/lib/jira-workflow.mjs +36 -6
  32. package/lib/launcher.mjs +303 -0
  33. package/lib/logwork-config.mjs +64 -13
  34. package/lib/manual-apply-state.mjs +15 -0
  35. package/lib/manual-commands.mjs +2 -2
  36. package/lib/manual-ink-app.mjs +142 -1282
  37. package/lib/manual-ink-draft-actions.mjs +227 -0
  38. package/lib/manual-ink-interaction.mjs +318 -0
  39. package/lib/manual-ink-session.mjs +41 -0
  40. package/lib/manual-ink-task-editor.mjs +338 -0
  41. package/lib/manual-ink-ui.mjs +38 -0
  42. package/lib/manual-ink-wizard.mjs +520 -0
  43. package/lib/manual-mcp.mjs +78 -20
  44. package/lib/manual-repl.mjs +171 -22
  45. package/lib/mcp-install.mjs +930 -0
  46. package/lib/mcp-server.mjs +51 -20
  47. package/lib/node-runtime.mjs +155 -0
  48. package/lib/paths.mjs +7 -0
  49. package/lib/project-mapping-workflow.mjs +32 -27
  50. package/lib/query-workflow.mjs +19 -1
  51. package/lib/reconciliation-workflow.mjs +1 -1
  52. package/lib/reminder-config.mjs +38 -2
  53. package/lib/reminder-scheduler.mjs +85 -18
  54. package/lib/reminder-service.mjs +42 -3
  55. package/lib/reminder-workflow.mjs +118 -22
  56. package/lib/repo-project-suggestions.mjs +285 -0
  57. package/lib/ro-api-diagnostics.mjs +109 -0
  58. package/lib/ro-api-entries.mjs +359 -0
  59. package/lib/ro-api-fields.mjs +290 -0
  60. package/lib/ro-api-http.mjs +68 -0
  61. package/lib/ro-api-projects.mjs +137 -0
  62. package/lib/ro-api-tasks.mjs +50 -0
  63. package/lib/ro-api-timesheet.mjs +413 -0
  64. package/lib/ro-delete-workflow.mjs +2 -1
  65. package/lib/ro-entry-reader.mjs +11 -6
  66. package/lib/ro-resubmit-workflow.mjs +2 -1
  67. package/lib/terminal-auth-prompts.mjs +7 -4
  68. package/lib/ui.mjs +121 -56
  69. package/package.json +11 -4
  70. package/skills/logwork/SKILL.md +10 -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) : '';
@@ -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
  }) {
@@ -72,9 +75,153 @@ export async function previewLogworkBatch({
72
75
  : enriched;
73
76
  // typeOfWork also changes what gets written, so it is folded into the
74
77
  // batchId the same way.
75
- return typeOfWork
78
+ const typed = typeOfWork
76
79
  ? { ...output, typeOfWork, batchId: `${output.batchId}_${hash(`typeOfWork:${typeOfWork}`)}`, summary: `${output.summary}\n\nType of work for every entry: ${typeOfWork}` }
77
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() !== '';
78
225
  }
79
226
 
80
227
  export const LOGWORK_LINE_FORMAT = '+<hours> <worklog task> | <description>';
@@ -313,9 +460,11 @@ export async function applyLogworkBatch({
313
460
  confirm,
314
461
  projectOverrides = {},
315
462
  allowUnbooked = false,
463
+ allowDuplicates = false,
316
464
  cwd = process.cwd(),
317
465
  fetchProjects,
318
466
  fetchMembershipProjects,
467
+ fetchDayEntries,
319
468
  submitEntry,
320
469
  verifyLogwork,
321
470
  recordLedger,
@@ -332,7 +481,8 @@ export async function applyLogworkBatch({
332
481
  projectOverrides,
333
482
  cwd,
334
483
  fetchProjects,
335
- fetchMembershipProjects
484
+ fetchMembershipProjects,
485
+ fetchDayEntries
336
486
  });
337
487
 
338
488
  if (preview.errors?.length) {
@@ -351,6 +501,11 @@ export async function applyLogworkBatch({
351
501
  throw new Error(`Cannot apply ${unbookedEntries.length} unbooked entries without allowUnbooked: true. ${blocked}`);
352
502
  }
353
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
+
354
509
  const results = [];
355
510
  const taskCache = new Map();
356
511
  const entrySubmitter = submitEntry || createLogtimeSubmitter(taskCache);
@@ -474,6 +629,36 @@ function createBookedProjectsFetcher() {
474
629
  };
475
630
  }
476
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
+
477
662
  function createMembershipProjectsFetcher() {
478
663
  const session = createResourceOptimiserSession();
479
664
  return async () => {