logwork-cli 1.1.1 → 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 (64) hide show
  1. package/README.md +23 -7
  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 +50 -16
  8. package/commands/tui.mjs +1 -0
  9. package/docs/advanced.md +12 -2
  10. package/docs/mcp-setup.md +56 -14
  11. package/docs/operations-guide.md +35 -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-workflow.mjs +187 -2
  25. package/lib/commit-workflow.mjs +188 -21
  26. package/lib/config.mjs +7 -0
  27. package/lib/diagnostics.mjs +53 -4
  28. package/lib/jira-api.mjs +25 -3
  29. package/lib/jira-workflow.mjs +36 -6
  30. package/lib/logwork-config.mjs +64 -13
  31. package/lib/manual-apply-state.mjs +15 -0
  32. package/lib/manual-commands.mjs +2 -2
  33. package/lib/manual-ink-app.mjs +108 -1281
  34. package/lib/manual-ink-draft-actions.mjs +227 -0
  35. package/lib/manual-ink-interaction.mjs +306 -0
  36. package/lib/manual-ink-session.mjs +41 -0
  37. package/lib/manual-ink-task-editor.mjs +338 -0
  38. package/lib/manual-ink-wizard.mjs +520 -0
  39. package/lib/manual-mcp.mjs +27 -15
  40. package/lib/manual-repl.mjs +39 -3
  41. package/lib/mcp-server.mjs +51 -20
  42. package/lib/node-runtime.mjs +115 -0
  43. package/lib/paths.mjs +7 -0
  44. package/lib/project-mapping-workflow.mjs +30 -7
  45. package/lib/query-workflow.mjs +0 -1
  46. package/lib/reconciliation-workflow.mjs +1 -1
  47. package/lib/reminder-config.mjs +38 -2
  48. package/lib/reminder-scheduler.mjs +54 -3
  49. package/lib/reminder-service.mjs +28 -3
  50. package/lib/reminder-workflow.mjs +118 -22
  51. package/lib/ro-api-diagnostics.mjs +109 -0
  52. package/lib/ro-api-entries.mjs +359 -0
  53. package/lib/ro-api-fields.mjs +290 -0
  54. package/lib/ro-api-http.mjs +68 -0
  55. package/lib/ro-api-projects.mjs +137 -0
  56. package/lib/ro-api-tasks.mjs +50 -0
  57. package/lib/ro-api-timesheet.mjs +413 -0
  58. package/lib/ro-delete-workflow.mjs +2 -1
  59. package/lib/ro-entry-reader.mjs +11 -6
  60. package/lib/ro-resubmit-workflow.mjs +2 -1
  61. package/lib/terminal-auth-prompts.mjs +7 -4
  62. package/lib/ui.mjs +80 -59
  63. package/package.json +11 -4
  64. package/skills/logwork/SKILL.md +5 -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 () => {
@@ -3,6 +3,9 @@ import { homedir } from 'node:os';
3
3
  import { resolve } from 'node:path';
4
4
  import { isAuthRequiredError } from './auth-errors.mjs';
5
5
  import { CONFIG } from './config.mjs';
6
+ import { findRepoMapping, loadLocalConfig } from './logwork-config.mjs';
7
+ import { projectIdentityKey, sameProjectIdentity } from './project-identity.mjs';
8
+ import { resolveEntryProject } from './project-resolver.mjs';
6
9
  import { normalizeQueryRange, queryLogwork } from './query-workflow.mjs';
7
10
  import { addLocalDays, todayLocalDateISO } from './util.mjs';
8
11
 
@@ -10,8 +13,11 @@ import { addLocalDays, todayLocalDateISO } from './util.mjs';
10
13
  // commits (all branches, this author, in the period)
11
14
  // -> grouped per day by repo + ticket + kind of work
12
15
  // -> each group gets a default RO worklog task and a description
13
- // -> the day's hours (RO booked - logged, or a standard day) are split across
14
- // groups by how much work each group holds
16
+ // -> each group gets an RO project: a mapping whose repos list holds the
17
+ // repo, else the project resolver (tickets, keywords, booked projects)
18
+ // -> each project's remaining hours (RO booked - logged) are split across its
19
+ // groups by how much work each group holds; unresolved groups share the
20
+ // room of booked projects no group claimed (without RO: a standard day)
15
21
  // -> a "+<hours> <worklog task> | <description>" block for log_work.
16
22
  // Nothing is written anywhere; the agent shows the draft, the user edits and
17
23
  // approves it, then log_work previews and applies it as usual.
@@ -115,8 +121,16 @@ export function parseGitLog(output = '') {
115
121
  return commits;
116
122
  }
117
123
 
124
+ export async function resolveGitRepoRoot(path, runGit = runGitCommand) {
125
+ const root = String(await runGit(path, ['rev-parse', '--show-toplevel'])).trim();
126
+ if (!root) {
127
+ throw new Error('git did not report a repository root');
128
+ }
129
+ return resolve(root);
130
+ }
131
+
118
132
  export async function readRepoCommits({ repo, from, to, author, runGit = runGitCommand }) {
119
- const root = (await runGit(repo, ['rev-parse', '--show-toplevel'])).trim();
133
+ const root = await resolveGitRepoRoot(repo, runGit);
120
134
  const authorFilter = (author || (await runGit(root, ['config', 'user.email']).catch(() => ''))).trim();
121
135
  if (!authorFilter) {
122
136
  throw new Error(`No git author for ${root}. Set git config user.email or pass author.`);
@@ -285,31 +299,111 @@ function isWeekend(localDateISO) {
285
299
  }
286
300
 
287
301
  // Remaining RO time per day: sum over projects of max(0, booked - logged), so
288
- // over-logging one project does not hide a gap on another.
302
+ // over-logging one project does not hide a gap on another. projects keeps every
303
+ // booked project with its ids so commit groups can be allocated per project.
289
304
  export function remainingHoursByDay(roResult) {
290
305
  const byDay = new Map();
291
306
  for (const day of roResult?.days || []) {
292
- const projects = day.projects || [];
307
+ const projects = (day.projects || []).map((project) => ({
308
+ ...projectRef(project),
309
+ bookedHours: Number(project.bookedHours || 0),
310
+ loggedHours: Number(project.loggedHours || 0),
311
+ remainingHours: roundHours(Math.max(0, Number(project.bookedHours || 0) - Number(project.loggedHours || 0)))
312
+ }));
293
313
  const remaining = projects.length
294
- ? projects.reduce((sum, project) => sum + Math.max(0, Number(project.bookedHours || 0) - Number(project.loggedHours || 0)), 0)
314
+ ? projects.reduce((sum, project) => sum + project.remainingHours, 0)
295
315
  : Math.max(0, Number(day.bookedHours || 0) - Number(day.loggedHours || 0));
296
316
  byDay.set(day.date, {
297
317
  bookedHours: Number(day.bookedHours || 0),
298
318
  loggedHours: Number(day.loggedHours || 0),
299
- remainingHours: Number(remaining.toFixed(2)),
300
- // The draft does not know which project a commit belongs to; this lets
301
- // the agent move lines to the projects that still have room.
319
+ remainingHours: roundHours(remaining),
320
+ projects,
302
321
  remainingByProject: projects
303
- .map((project) => ({
304
- projectName: project.projectName,
305
- remainingHours: Number(Math.max(0, Number(project.bookedHours || 0) - Number(project.loggedHours || 0)).toFixed(2))
306
- }))
307
322
  .filter((project) => project.remainingHours > 0)
323
+ .map((project) => ({ ...projectRef(project), remainingHours: project.remainingHours }))
308
324
  });
309
325
  }
310
326
  return byDay;
311
327
  }
312
328
 
329
+ // The project of one commit group: a mapping whose repos list holds the repo,
330
+ // else the shared resolver on the description and tickets against the day's
331
+ // booked projects and the ticket/keyword mappings, else null.
332
+ export function resolveGroupProject(group, { bookedProjects = [], config = {}, platform = process.platform } = {}) {
333
+ const mapping = findRepoMapping(config.projectMappings, group.repo, platform);
334
+ if (mapping) {
335
+ const booked = bookedProjects.find((project) => sameProjectIdentity(project, mapping));
336
+ return { ...projectRef(booked || mapping), source: 'repo_mapping' };
337
+ }
338
+ if (!bookedProjects.length) {
339
+ return null;
340
+ }
341
+ const resolution = resolveEntryProject({
342
+ entry: { id: group.repo, text: group.description, taskName: group.description, tickets: group.tickets },
343
+ projects: bookedProjects,
344
+ config
345
+ });
346
+ if (resolution.status !== 'resolved') {
347
+ return null;
348
+ }
349
+ return { ...projectRef(resolution.project), source: 'resolver', reason: resolution.reason };
350
+ }
351
+
352
+ // Splits one day's RO room per project:
353
+ // - groups resolved to a project share that project's remaining hours;
354
+ // - unresolved groups share the remaining hours of booked projects that got
355
+ // no resolved group;
356
+ // - nothing ever exceeds a project's remaining hours.
357
+ export function allocateDayByProject(groups, projects) {
358
+ const hours = new Array(groups.length).fill(0);
359
+ const byProject = new Map();
360
+ const unresolved = [];
361
+ groups.forEach((group, index) => {
362
+ if (!group.project) {
363
+ unresolved.push(index);
364
+ return;
365
+ }
366
+ const key = projectIdentityKey(group.project);
367
+ const entry = byProject.get(key) || { project: group.project, indexes: [] };
368
+ entry.indexes.push(index);
369
+ byProject.set(key, entry);
370
+ });
371
+
372
+ const claimed = new Set();
373
+ const allocation = [];
374
+ for (const { project, indexes } of byProject.values()) {
375
+ const booked = projects.find((candidate) => sameProjectIdentity(candidate, project));
376
+ if (booked) {
377
+ claimed.add(booked);
378
+ }
379
+ const remainingHours = booked?.remainingHours ?? 0;
380
+ const split = allocateHours(indexes.map((index) => groups[index]), remainingHours);
381
+ indexes.forEach((index, position) => { hours[index] = split[position]; });
382
+ allocation.push({
383
+ ...projectRef(project),
384
+ booked: Boolean(booked),
385
+ remainingHours,
386
+ allocatedHours: roundHours(split.reduce((sum, value) => sum + value, 0)),
387
+ groupCount: indexes.length
388
+ });
389
+ }
390
+
391
+ const unclaimedHours = roundHours(projects
392
+ .filter((project) => !claimed.has(project))
393
+ .reduce((sum, project) => sum + project.remainingHours, 0));
394
+ if (unresolved.length) {
395
+ const split = allocateHours(unresolved.map((index) => groups[index]), unclaimedHours);
396
+ unresolved.forEach((index, position) => { hours[index] = split[position]; });
397
+ }
398
+ return {
399
+ hours,
400
+ allocation,
401
+ unresolved: unresolved.length
402
+ ? { groupCount: unresolved.length, roomHours: unclaimedHours, allocatedHours: roundHours(unresolved.reduce((sum, index) => sum + hours[index], 0)) }
403
+ : null
404
+ };
405
+ }
406
+
313
407
  export async function draftLogworkFromCommits({
314
408
  period = 'today',
315
409
  from,
@@ -319,7 +413,9 @@ export async function draftLogworkFromCommits({
319
413
  useBookings = true,
320
414
  cwd = process.cwd(),
321
415
  runGit = runGitCommand,
322
- queryRo = queryLogwork
416
+ queryRo = queryLogwork,
417
+ loadConfig = loadLocalConfig,
418
+ platform = process.platform
323
419
  } = {}) {
324
420
  const range = from || to ? normalizeQueryRange({ from, to }) : normalizeQueryRange({ period });
325
421
  const repoPaths = [...new Set((repos?.length ? repos : [cwd]).map((repo) => resolve(cwd, expandHome(String(repo)))))];
@@ -360,14 +456,20 @@ export async function draftLogworkFromCommits({
360
456
  }
361
457
  }
362
458
 
459
+ const config = commits.length ? await loadConfig(cwd) : { projectMappings: [] };
460
+
363
461
  const days = [];
364
462
  for (let date = range.from; date < range.to; date = addLocalDays(date, 1)) {
365
463
  const dayCommits = commits.filter((commit) => commit.date === date);
366
464
  if (!dayCommits.length) {
367
465
  continue;
368
466
  }
369
- const groups = groupCommits(dayCommits);
370
467
  const booking = bookings?.get(date);
468
+ const bookedProjects = (booking?.projects || []).filter((project) => project.bookedHours > 0);
469
+ const groups = groupCommits(dayCommits).map((group) => ({
470
+ ...group,
471
+ project: resolveGroupProject(group, { bookedProjects, config, platform })
472
+ }));
371
473
  let targetHours;
372
474
  let hoursSource;
373
475
  if (booking) {
@@ -381,12 +483,19 @@ export async function draftLogworkFromCommits({
381
483
  targetHours = isWeekend(date) ? 0 : (CONFIG.standardDayHours || 8);
382
484
  hoursSource = isWeekend(date) ? 'weekend' : 'standard_day';
383
485
  }
384
- const hours = allocateHours(groups, targetHours);
486
+ // Per-project split only when RO returned identifiable per-project rows
487
+ // for the day; otherwise (RO unavailable, no booking, day-level totals or
488
+ // nameless rows) the day's target is split across all groups as before.
489
+ const perProject = Boolean(booking?.projects?.length) &&
490
+ booking.projects.every((project) => projectIdentityKey(project) !== '');
491
+ const split = perProject ? allocateDayByProject(groups, booking.projects) : null;
492
+ const hours = split ? split.hours : allocateHours(groups, targetHours);
385
493
  days.push({
386
494
  date,
387
495
  targetHours,
388
496
  hoursSource,
389
497
  ...(booking ? { bookedHours: booking.bookedHours, loggedHours: booking.loggedHours, remainingByProject: booking.remainingByProject } : {}),
498
+ ...(split ? { projectAllocation: split.allocation, unresolvedAllocation: split.unresolved } : {}),
390
499
  groups: groups.map((group, index) => ({
391
500
  hours: hours[index],
392
501
  task: group.task,
@@ -394,6 +503,8 @@ export async function draftLogworkFromCommits({
394
503
  description: group.description,
395
504
  tickets: group.tickets,
396
505
  repo: group.repoName,
506
+ repoPath: group.repo,
507
+ project: group.project,
397
508
  commitCount: group.commitCount,
398
509
  linesChanged: group.linesChanged,
399
510
  commits: group.hashes
@@ -401,15 +512,32 @@ export async function draftLogworkFromCommits({
401
512
  });
402
513
  }
403
514
 
515
+ // log_work preview entry ids are "<date>-<NN>", NN counting entry lines
516
+ // through the whole text (lib/batch-parser.mjs), so they are numbered here in
517
+ // the same order the lines are written.
518
+ const projectOverrides = {};
519
+ let lineIndex = 0;
404
520
  const draftText = days
405
521
  .map((day) => {
406
- const lines = day.groups.filter((group) => group.hours > 0)
407
- .map((group) => `+${group.hours} ${group.task} | ${group.description}`);
522
+ const lines = [];
523
+ for (const group of day.groups.filter((item) => item.hours > 0)) {
524
+ lineIndex += 1;
525
+ const entryId = `${day.date}-${String(lineIndex).padStart(2, '0')}`;
526
+ group.entryId = entryId;
527
+ if (hasValue(group.project?.projectMemberId)) {
528
+ projectOverrides[entryId] = group.project.projectMemberId;
529
+ }
530
+ lines.push(`+${group.hours} ${group.task} | ${group.description}`);
531
+ }
408
532
  return lines.length ? [formatDayHeading(day.date), ...lines].join('\n') : '';
409
533
  })
410
534
  .filter(Boolean)
411
535
  .join('\n\n');
412
536
 
537
+ const unmappedRepos = [...new Set(days.flatMap((day) => day.groups)
538
+ .filter((group) => !group.project)
539
+ .map((group) => group.repoPath))];
540
+
413
541
  const result = {
414
542
  status: !commits.length ? 'no_commits' : (draftText ? 'draft' : 'no_hours'),
415
543
  range,
@@ -418,7 +546,9 @@ export async function draftLogworkFromCommits({
418
546
  ...(bookingsError ? { bookingsError } : {}),
419
547
  commitCount: commits.length,
420
548
  days,
421
- draftText
549
+ draftText,
550
+ projectOverrides,
551
+ unmappedRepos
422
552
  };
423
553
  result.summary = formatCommitDraftSummary(result);
424
554
  return result;
@@ -446,18 +576,55 @@ export function formatCommitDraftSummary(result) {
446
576
  }[day.hoursSource];
447
577
  lines.push('', `${formatDayHeading(day.date)} - ${source}`);
448
578
  if (day.remainingByProject?.length) {
449
- lines.push(` Room by project: ${day.remainingByProject.map((project) => `${project.projectName} ${project.remainingHours}h`).join(', ')}`);
579
+ lines.push(` Room by project: ${day.remainingByProject.map((project) => `${projectLabel(project)} ${project.remainingHours}h`).join(', ')}`);
580
+ }
581
+ for (const project of day.projectAllocation || []) {
582
+ if (project.allocatedHours === 0) {
583
+ lines.push(` No room on ${projectLabel(project)}${project.booked ? ` (remaining ${project.remainingHours}h)` : ' (not booked this day)'}: its ${project.groupCount} line(s) get 0h.`);
584
+ }
585
+ }
586
+ if (day.unresolvedAllocation) {
587
+ lines.push(` ${day.unresolvedAllocation.groupCount} line(s) without a project share ${day.unresolvedAllocation.roomHours}h left on booked projects no other line claimed.`);
450
588
  }
451
589
  for (const group of day.groups) {
452
- lines.push(` +${group.hours} ${group.task} | ${group.description} [${group.repo}, ${group.commitCount} commit(s)]`);
590
+ const project = group.project
591
+ ? `${projectLabel(group.project)} (${group.project.source === 'repo_mapping' ? 'repo mapping' : group.project.reason || group.project.source})`
592
+ : 'project unresolved';
593
+ lines.push(` ${group.entryId ? `${group.entryId} ` : ''}+${group.hours} ${group.task} | ${group.description} [${group.repo}, ${group.commitCount} commit(s)] -> ${project}`);
453
594
  }
454
595
  }
455
596
  if (result.draftText) {
456
597
  lines.push('', 'Draft for log_work (review with the user first; worklog task names must exist in list_tasks for the project):', '', result.draftText);
598
+ if (Object.keys(result.projectOverrides || {}).length) {
599
+ lines.push('', `Pass projectOverrides to log_work with this draftText unchanged (renumbered or edited lines need new ids): ${JSON.stringify(result.projectOverrides)}`);
600
+ }
601
+ }
602
+ if (result.unmappedRepos?.length) {
603
+ lines.push('', `No project for some lines from: ${result.unmappedRepos.join(', ')}. Ask the user which RO project each repo belongs to and offer map_project with repos so later drafts know it.`);
457
604
  }
458
605
  return lines.join('\n');
459
606
  }
460
607
 
608
+ function projectRef(project = {}) {
609
+ return {
610
+ projectName: project.projectName || '',
611
+ ...(hasValue(project.projectMemberId) ? { projectMemberId: project.projectMemberId } : {}),
612
+ ...(hasValue(project.projectId) ? { projectId: project.projectId } : {})
613
+ };
614
+ }
615
+
616
+ function projectLabel(project = {}) {
617
+ return project.projectName || (hasValue(project.projectMemberId) ? `project member ${project.projectMemberId}` : 'unnamed project');
618
+ }
619
+
620
+ function hasValue(value) {
621
+ return value !== undefined && value !== null && String(value).trim() !== '';
622
+ }
623
+
624
+ function roundHours(value) {
625
+ return Number(Number(value || 0).toFixed(2));
626
+ }
627
+
461
628
  function expandHome(path) {
462
629
  return path === '~' || /^~[\\/]/.test(path) ? `${homedir()}${path.slice(1)}` : path;
463
630
  }
package/lib/config.mjs CHANGED
@@ -45,6 +45,13 @@ const BASE_CONFIG = {
45
45
 
46
46
  export const CONFIG = buildConfig(process.env);
47
47
 
48
+ // LOGWORK_DRY_RUN=1 turns every RO and Jira write into a returned request
49
+ // ({ dryRun: true, method, path, body }). Read at call time so tests and the
50
+ // MCP server can toggle it without reloading CONFIG. Reads still hit the API.
51
+ export function isDryRun(env = process.env) {
52
+ return env.LOGWORK_DRY_RUN === '1';
53
+ }
54
+
48
55
  export function buildConfig(env = {}) {
49
56
  const profile = readStringEnv(env, 'LOGWORK_PROFILE') || readStringEnv(env, 'LOGWORK_HELPER_PROFILE') || DEFAULT_PROFILE;
50
57
  const config = {