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
@@ -1,6 +1,11 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { homedir } from 'node:os';
2
3
  import { resolve } from 'node:path';
4
+ import { isAuthRequiredError } from './auth-errors.mjs';
3
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';
4
9
  import { normalizeQueryRange, queryLogwork } from './query-workflow.mjs';
5
10
  import { addLocalDays, todayLocalDateISO } from './util.mjs';
6
11
 
@@ -8,13 +13,20 @@ import { addLocalDays, todayLocalDateISO } from './util.mjs';
8
13
  // commits (all branches, this author, in the period)
9
14
  // -> grouped per day by repo + ticket + kind of work
10
15
  // -> each group gets a default RO worklog task and a description
11
- // -> the day's hours (RO booked - logged, or a standard day) are split across
12
- // 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)
13
21
  // -> a "+<hours> <worklog task> | <description>" block for log_work.
14
22
  // Nothing is written anywhere; the agent shows the draft, the user edits and
15
23
  // approves it, then log_work previews and applies it as usual.
16
24
 
17
25
  const TICKET_RE = /\b[A-Z][A-Z0-9]+-\d+\b/g;
26
+ // Prefixes that look like ticket keys but are standards or versions
27
+ // ("UTF-8", "ISO-8601", "SHA-256", "CVE-2024-1234").
28
+ const NOT_TICKET_PREFIXES = new Set(['UTF', 'ISO', 'SHA', 'RFC', 'HTTP', 'HTTPS', 'ES', 'ECMA', 'TLS', 'SSL', 'MD', 'AES', 'RSA', 'UTC', 'GMT', 'CVE', 'CWE', 'IPV', 'WCAG', 'PEP', 'JSR', 'ISBN', 'COVID', 'MP', 'H', 'X', 'IE', 'WIN', 'NODE', 'PHP', 'IOS']);
29
+ const MAX_TICKETS_IN_DESCRIPTION = 4;
18
30
  const CONVENTIONAL_RE = /^(\w+)(?:\([^)]*\))?!?:\s*/;
19
31
  const FIELD = '\x1f';
20
32
  const RECORD = '\x1e';
@@ -109,38 +121,87 @@ export function parseGitLog(output = '') {
109
121
  return commits;
110
122
  }
111
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
+
112
132
  export async function readRepoCommits({ repo, from, to, author, runGit = runGitCommand }) {
113
- const root = (await runGit(repo, ['rev-parse', '--show-toplevel'])).trim();
114
- const authorFilter = author || (await runGit(root, ['config', 'user.email']).catch(() => '')).trim();
133
+ const root = await resolveGitRepoRoot(repo, runGit);
134
+ const authorFilter = (author || (await runGit(root, ['config', 'user.email']).catch(() => ''))).trim();
115
135
  if (!authorFilter) {
116
136
  throw new Error(`No git author for ${root}. Set git config user.email or pass author.`);
117
137
  }
118
- const output = await runGit(root, [
138
+ // The period is matched on the author date in JS below. Git's --since and
139
+ // --until use the committer date (so rebased or amended commits would fall
140
+ // out of their real day) and --since stops the walk at the first older
141
+ // commit (clock skew). --since-as-filter (git 2.37+) only filters, and a day
142
+ // of slack covers time zones; older git falls back to a 31-day window.
143
+ const sinceDate = addLocalDays(from, -1);
144
+ const baseArgs = [
119
145
  'log',
120
- // Every local and remote branch (work often sits on feature branches), but
121
- // not refs/stash, whose "WIP on"/"index on" commits are not work items.
146
+ // Every local and remote branch (work often sits on feature branches) and
147
+ // HEAD (a detached HEAD mid-rebase), but not refs/stash, whose "WIP on" /
148
+ // "index on" commits are not work items.
122
149
  '--branches',
123
150
  '--remotes',
151
+ 'HEAD',
124
152
  '--no-merges',
125
- `--since=${from}T00:00:00`,
126
- `--until=${to}T00:00:00`,
153
+ // Narrow by author in git (fixed string, so "." or "+" in an email is not
154
+ // a regex); the exact match happens in JS.
127
155
  `--author=${authorFilter}`,
156
+ '--fixed-strings',
128
157
  '--regexp-ignore-case',
129
158
  `--format=${RECORD}%H${FIELD}%at${FIELD}%an${FIELD}%ae${FIELD}%s${FIELD}%b${FIELD}`,
130
159
  '--numstat'
131
- ]);
160
+ ];
161
+ let output;
162
+ try {
163
+ output = await runGit(root, [...baseArgs, `--since-as-filter=${sinceDate}T00:00:00`]);
164
+ } catch (error) {
165
+ if (!/since-as-filter|unrecognized argument|unknown option/i.test(error.message)) {
166
+ throw error;
167
+ }
168
+ output = await runGit(root, [...baseArgs, `--since=${addLocalDays(from, -31)}T00:00:00`]);
169
+ }
132
170
  const seen = new Set();
133
171
  const commits = parseGitLog(output)
172
+ .filter((commit) => matchesAuthor(commit, authorFilter))
134
173
  .filter((commit) => !seen.has(commit.hash) && seen.add(commit.hash))
135
174
  .map((commit) => ({ ...commit, repo: root, date: todayLocalDateISO(new Date(commit.timestamp * 1000)) }))
136
175
  .filter((commit) => commit.date >= from && commit.date < to);
137
176
  return { root, author: authorFilter, commits };
138
177
  }
139
178
 
179
+ // Exact author match: an email filter must equal the author email, a name
180
+ // filter must equal the author name (both case-insensitive). git --author is
181
+ // a substring match, so "me@x.com" alone would also match "jime@x.com".
182
+ export function matchesAuthor(commit, authorFilter) {
183
+ const filter = String(authorFilter || '').trim().toLowerCase();
184
+ if (!filter) {
185
+ return true;
186
+ }
187
+ const email = String(commit.authorEmail || '').toLowerCase();
188
+ const name = String(commit.authorName || '').toLowerCase();
189
+ const bracketed = filter.match(/<([^>]+)>/);
190
+ if (bracketed) {
191
+ return email === bracketed[1].trim();
192
+ }
193
+ return filter.includes('@') ? email === filter : name === filter;
194
+ }
195
+
196
+ export function extractTickets(text = '') {
197
+ return [...new Set((String(text).match(TICKET_RE) || [])
198
+ .filter((key) => !NOT_TICKET_PREFIXES.has(key.slice(0, key.lastIndexOf('-')))))];
199
+ }
200
+
140
201
  export function groupCommits(commits = []) {
141
202
  const groups = new Map();
142
203
  for (const commit of [...commits].sort((a, b) => a.timestamp - b.timestamp)) {
143
- const tickets = [...new Set(`${commit.subject}\n${commit.body}`.match(TICKET_RE) || [])];
204
+ const tickets = extractTickets(`${commit.subject}\n${commit.body}`);
144
205
  const kind = classifyCommit(commit.subject, commit.body);
145
206
  const key = [commit.repo, tickets[0] || '', kind].join('|');
146
207
  const group = groups.get(key) || {
@@ -172,9 +233,14 @@ export function groupCommits(commits = []) {
172
233
  }
173
234
 
174
235
  export function buildDescription(group) {
175
- const ticketSuffix = group.tickets.length ? ` (${group.tickets.join(', ')})` : '';
176
- const room = MAX_DESCRIPTION_LENGTH - ticketSuffix.length;
177
- const subjects = group.subjects.map((subject) => subject.replace(TICKET_RE, '').replace(/\s*[[(]\s*[)\]]\s*/g, ' ').replace(/\s+/g, ' ').trim()).filter(Boolean);
236
+ const shown = group.tickets.slice(0, MAX_TICKETS_IN_DESCRIPTION);
237
+ const more = group.tickets.length - shown.length;
238
+ const ticketSuffix = shown.length ? ` (${shown.join(', ')}${more > 0 ? ` +${more} more` : ''})` : '';
239
+ const room = Math.max(40, MAX_DESCRIPTION_LENGTH - ticketSuffix.length);
240
+ const ticketSet = new Set(group.tickets);
241
+ const subjects = group.subjects
242
+ .map((subject) => subject.replace(TICKET_RE, (key) => (ticketSet.has(key) ? '' : key)).replace(/\s*[[(]\s*[)\]]\s*/g, ' ').replace(/\s+/g, ' ').trim())
243
+ .filter(Boolean);
178
244
  let text = '';
179
245
  for (const subject of subjects) {
180
246
  const next = text ? `${text}; ${subject}` : subject;
@@ -233,23 +299,111 @@ function isWeekend(localDateISO) {
233
299
  }
234
300
 
235
301
  // Remaining RO time per day: sum over projects of max(0, booked - logged), so
236
- // 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.
237
304
  export function remainingHoursByDay(roResult) {
238
305
  const byDay = new Map();
239
306
  for (const day of roResult?.days || []) {
240
- 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
+ }));
241
313
  const remaining = projects.length
242
- ? 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)
243
315
  : Math.max(0, Number(day.bookedHours || 0) - Number(day.loggedHours || 0));
244
316
  byDay.set(day.date, {
245
317
  bookedHours: Number(day.bookedHours || 0),
246
318
  loggedHours: Number(day.loggedHours || 0),
247
- remainingHours: Number(remaining.toFixed(2))
319
+ remainingHours: roundHours(remaining),
320
+ projects,
321
+ remainingByProject: projects
322
+ .filter((project) => project.remainingHours > 0)
323
+ .map((project) => ({ ...projectRef(project), remainingHours: project.remainingHours }))
248
324
  });
249
325
  }
250
326
  return byDay;
251
327
  }
252
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
+
253
407
  export async function draftLogworkFromCommits({
254
408
  period = 'today',
255
409
  from,
@@ -259,18 +413,29 @@ export async function draftLogworkFromCommits({
259
413
  useBookings = true,
260
414
  cwd = process.cwd(),
261
415
  runGit = runGitCommand,
262
- queryRo = queryLogwork
416
+ queryRo = queryLogwork,
417
+ loadConfig = loadLocalConfig,
418
+ platform = process.platform
263
419
  } = {}) {
264
420
  const range = from || to ? normalizeQueryRange({ from, to }) : normalizeQueryRange({ period });
265
- const repoPaths = [...new Set((repos?.length ? repos : [cwd]).map((repo) => resolve(cwd, String(repo))))];
421
+ const repoPaths = [...new Set((repos?.length ? repos : [cwd]).map((repo) => resolve(cwd, expandHome(String(repo)))))];
266
422
 
267
423
  const repoResults = [];
268
424
  const commits = [];
425
+ const seenRoots = new Set();
426
+ // A commit counts once even when two paths reach it (a subfolder of the same
427
+ // repo, or a git worktree of it).
428
+ const seenHashes = new Set();
269
429
  for (const repo of repoPaths) {
270
430
  try {
271
431
  const result = await readRepoCommits({ repo, from: range.from, to: range.to, author, runGit });
272
- repoResults.push({ path: result.root, author: result.author, commitCount: result.commits.length });
273
- commits.push(...result.commits);
432
+ if (seenRoots.has(result.root)) {
433
+ continue;
434
+ }
435
+ seenRoots.add(result.root);
436
+ const fresh = result.commits.filter((commit) => !seenHashes.has(commit.hash) && seenHashes.add(commit.hash));
437
+ repoResults.push({ path: result.root, author: result.author, commitCount: fresh.length });
438
+ commits.push(...fresh);
274
439
  } catch (error) {
275
440
  repoResults.push({ path: repo, error: error.message, commitCount: 0 });
276
441
  }
@@ -282,18 +447,29 @@ export async function draftLogworkFromCommits({
282
447
  try {
283
448
  bookings = remainingHoursByDay(await queryRo({ from: range.from, to: range.to, includeEntries: false, cwd }));
284
449
  } catch (error) {
450
+ // A missing or expired RO login must reach the agent as authRequired;
451
+ // falling back to an 8h day would hide hours that are already logged.
452
+ if (isAuthRequiredError(error)) {
453
+ throw error;
454
+ }
285
455
  bookingsError = error.message;
286
456
  }
287
457
  }
288
458
 
459
+ const config = commits.length ? await loadConfig(cwd) : { projectMappings: [] };
460
+
289
461
  const days = [];
290
462
  for (let date = range.from; date < range.to; date = addLocalDays(date, 1)) {
291
463
  const dayCommits = commits.filter((commit) => commit.date === date);
292
464
  if (!dayCommits.length) {
293
465
  continue;
294
466
  }
295
- const groups = groupCommits(dayCommits);
296
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
+ }));
297
473
  let targetHours;
298
474
  let hoursSource;
299
475
  if (booking) {
@@ -307,12 +483,19 @@ export async function draftLogworkFromCommits({
307
483
  targetHours = isWeekend(date) ? 0 : (CONFIG.standardDayHours || 8);
308
484
  hoursSource = isWeekend(date) ? 'weekend' : 'standard_day';
309
485
  }
310
- 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);
311
493
  days.push({
312
494
  date,
313
495
  targetHours,
314
496
  hoursSource,
315
- ...(booking ? { bookedHours: booking.bookedHours, loggedHours: booking.loggedHours } : {}),
497
+ ...(booking ? { bookedHours: booking.bookedHours, loggedHours: booking.loggedHours, remainingByProject: booking.remainingByProject } : {}),
498
+ ...(split ? { projectAllocation: split.allocation, unresolvedAllocation: split.unresolved } : {}),
316
499
  groups: groups.map((group, index) => ({
317
500
  hours: hours[index],
318
501
  task: group.task,
@@ -320,6 +503,8 @@ export async function draftLogworkFromCommits({
320
503
  description: group.description,
321
504
  tickets: group.tickets,
322
505
  repo: group.repoName,
506
+ repoPath: group.repo,
507
+ project: group.project,
323
508
  commitCount: group.commitCount,
324
509
  linesChanged: group.linesChanged,
325
510
  commits: group.hashes
@@ -327,24 +512,43 @@ export async function draftLogworkFromCommits({
327
512
  });
328
513
  }
329
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;
330
520
  const draftText = days
331
521
  .map((day) => {
332
- const lines = day.groups.filter((group) => group.hours > 0)
333
- .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
+ }
334
532
  return lines.length ? [formatDayHeading(day.date), ...lines].join('\n') : '';
335
533
  })
336
534
  .filter(Boolean)
337
535
  .join('\n\n');
338
536
 
537
+ const unmappedRepos = [...new Set(days.flatMap((day) => day.groups)
538
+ .filter((group) => !group.project)
539
+ .map((group) => group.repoPath))];
540
+
339
541
  const result = {
340
- status: commits.length ? 'draft' : 'no_commits',
542
+ status: !commits.length ? 'no_commits' : (draftText ? 'draft' : 'no_hours'),
341
543
  range,
342
544
  repos: repoResults,
343
545
  bookings: useBookings ? (bookings ? 'ro' : 'unavailable') : 'skipped',
344
546
  ...(bookingsError ? { bookingsError } : {}),
345
547
  commitCount: commits.length,
346
548
  days,
347
- draftText
549
+ draftText,
550
+ projectOverrides,
551
+ unmappedRepos
348
552
  };
349
553
  result.summary = formatCommitDraftSummary(result);
350
554
  return result;
@@ -371,16 +575,60 @@ export function formatCommitDraftSummary(result) {
371
575
  standard_day: `standard day ${day.targetHours}h`
372
576
  }[day.hoursSource];
373
577
  lines.push('', `${formatDayHeading(day.date)} - ${source}`);
578
+ if (day.remainingByProject?.length) {
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.`);
588
+ }
374
589
  for (const group of day.groups) {
375
- 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}`);
376
594
  }
377
595
  }
378
596
  if (result.draftText) {
379
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.`);
380
604
  }
381
605
  return lines.join('\n');
382
606
  }
383
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
+
628
+ function expandHome(path) {
629
+ return path === '~' || /^~[\\/]/.test(path) ? `${homedir()}${path.slice(1)}` : path;
630
+ }
631
+
384
632
  function runGitCommand(cwd, args) {
385
633
  return new Promise((resolveValue, reject) => {
386
634
  const child = spawn('git', ['-C', cwd, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
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 = {
@@ -1,4 +1,3 @@
1
- import { spawn } from 'node:child_process';
2
1
  import { constants as fsConstants } from 'node:fs';
3
2
  import { access, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
4
3
  import { EOL, homedir, platform, release, type } from 'node:os';
@@ -11,7 +10,9 @@ import { getStoredAuthStatus } from './auth.mjs';
11
10
  import { redactText } from './auth-redaction.mjs';
12
11
  import { authDebugLogPath, sanitizeDiagnosticValue } from './auth-diagnostics.mjs';
13
12
  import { configPath, loadLocalConfig } from './logwork-config.mjs';
14
- import { helperHome, manualDraftsPath, userConfigPath } from './paths.mjs';
13
+ import { formatNodePathIssue, inspectNodePath, loadRuntimeInfo } from './node-runtime.mjs';
14
+ import { helperHome, manualDraftsPath, runtimeInfoPath, userConfigPath } from './paths.mjs';
15
+ import { readReminderSchedulerNodePath } from './reminder-scheduler.mjs';
15
16
 
16
17
  const DIAGNOSTICS_DIR_NAME = 'diagnostics';
17
18
  const MAX_AUTH_DEBUG_CHARS = 20_000;
@@ -35,6 +36,7 @@ export async function generateDiagnosticsReport({
35
36
  commandFinder = findCommandOnPath,
36
37
  authStatusFn = getStoredAuthStatus,
37
38
  mcpSmokeFn = runMcpSmoke,
39
+ nodeRuntimeFn = collectNodeRuntimeCheck,
38
40
  readFileFn = readFile,
39
41
  statFn = stat,
40
42
  writeFileFn = writeFile,
@@ -47,6 +49,8 @@ export async function generateDiagnosticsReport({
47
49
  const fileChecks = await collectFileChecks({ statFn, cwd });
48
50
  const configSnapshot = await safeCollect('config snapshot', () => collectConfigSnapshot({ cwd, includeMappingDetails }));
49
51
  const mcpSmoke = await safeCollect('mcp smoke', () => mcpSmokeFn({ cwd }));
52
+ const nodeRuntime = await safeCollect('node runtime', () => nodeRuntimeFn());
53
+ const nodeIssues = Array.isArray(nodeRuntime?.issues) ? nodeRuntime.issues : [];
50
54
  const authDebug = await readSanitizedAuthDebug({ readFileFn });
51
55
 
52
56
  sections.push(formatSection('logwork-cli Diagnostics', [
@@ -64,6 +68,17 @@ export async function generateDiagnosticsReport({
64
68
  `home: ${homedir()}`
65
69
  ]));
66
70
 
71
+ sections.push(formatSection('Node Path', nodeRuntime?.ok === false
72
+ ? formatObjectLines(nodeRuntime)
73
+ : [
74
+ `runtimeFile: ${nodeRuntime.runtimeFile}`,
75
+ `recordedNode: ${nodeRuntime.recordedNode || 'none (run logwork setup)'}${nodeRuntime.recordedVersion ? ` (${nodeRuntime.recordedVersion})` : ''}`,
76
+ `recordedNodeStatus: ${nodeRuntime.recordedStatus}`,
77
+ `reminderNode: ${nodeRuntime.reminderNode || 'none (no reminder schedule)'}`,
78
+ `reminderNodeStatus: ${nodeRuntime.reminderStatus}`,
79
+ ...(nodeIssues.length ? nodeIssues.map((issue) => `warning: ${issue}`) : ['warnings: none'])
80
+ ]));
81
+
67
82
  sections.push(formatSection('Commands', commandChecks.map((entry) => `${entry.command}: ${entry.available ? 'found' : 'missing'}${entry.path ? ` (${entry.path})` : ''}`)));
68
83
  sections.push(formatSection('Files', fileChecks.map((entry) => formatFileCheck(entry))));
69
84
  sections.push(formatSection('Config Snapshot', formatObjectLines(configSnapshot)));
@@ -78,7 +93,41 @@ export async function generateDiagnosticsReport({
78
93
  return {
79
94
  path: outputPath,
80
95
  report,
81
- summary: `Diagnostics report written to ${outputPath}`
96
+ nodeIssues: nodeIssues.map((issue) => sanitizeReport(issue)),
97
+ summary: [
98
+ ...nodeIssues.map((issue) => sanitizeReport(`Warning: ${issue}`)),
99
+ `Diagnostics report written to ${outputPath}`
100
+ ].join(EOL)
101
+ };
102
+ }
103
+
104
+ // Compares the node recorded by `logwork setup` (runtime.json) and the node in
105
+ // the installed reminder schedule with the node running now. Read-only.
106
+ export async function collectNodeRuntimeCheck({
107
+ loadRuntime = loadRuntimeInfo,
108
+ readSchedulerNodePath = readReminderSchedulerNodePath,
109
+ inspect = inspectNodePath
110
+ } = {}) {
111
+ const runtime = await loadRuntime().catch(() => null);
112
+ const reminderNode = await readSchedulerNodePath().catch(() => null);
113
+ const recorded = runtime?.nodePath
114
+ ? await inspect(runtime.nodePath)
115
+ : { status: 'unknown' };
116
+ const reminder = reminderNode
117
+ ? await inspect(reminderNode)
118
+ : { status: 'unknown' };
119
+ const issues = [
120
+ formatNodePathIssue('runtime', recorded),
121
+ formatNodePathIssue('reminder', reminder)
122
+ ].filter(Boolean);
123
+ return {
124
+ runtimeFile: runtimeInfoPath(),
125
+ recordedNode: runtime?.nodePath || '',
126
+ recordedVersion: runtime?.nodeVersion || '',
127
+ recordedStatus: recorded.status,
128
+ reminderNode: reminderNode || '',
129
+ reminderStatus: reminder.status,
130
+ issues
82
131
  };
83
132
  }
84
133
 
@@ -202,7 +251,7 @@ async function safeCollect(label, fn) {
202
251
  }
203
252
  }
204
253
 
205
- export async function runMcpSmoke({ cwd = process.cwd(), timeoutMs = MCP_SMOKE_TIMEOUT_MS } = {}) {
254
+ export async function runMcpSmoke({ timeoutMs = MCP_SMOKE_TIMEOUT_MS } = {}) {
206
255
  const client = new Client({
207
256
  name: 'logwork-diagnostics',
208
257
  version: '0.1.0'
package/lib/jira-api.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { CONFIG } from './config.mjs';
1
+ import { CONFIG, isDryRun } from './config.mjs';
2
2
  import { ApiError } from './api.mjs';
3
3
  import { fetchWithPolicy, redactedExcerpt } from './http.mjs';
4
4
  import { safeJsonParse } from './util.mjs';
@@ -15,6 +15,11 @@ export async function jiraApiFetch(token, path, {
15
15
  } = {}) {
16
16
  assertJiraToken(token);
17
17
  const url = buildJiraUrl(baseUrl, path);
18
+ // Every Jira write goes through here, so this one gate covers worklog create
19
+ // and any write added later. The result never carries the token or headers.
20
+ if (!isIdempotentMethod(method) && isDryRun()) {
21
+ return createJiraDryRunRequest(method, path, body);
22
+ }
18
23
  const response = await fetchWithPolicy(url.toString(), {
19
24
  fetchImpl,
20
25
  timeoutMs,
@@ -123,12 +128,29 @@ export async function addJiraIssueWorklog(token, issueKey, worklog, options = {}
123
128
  assertIssueKey(issueKey);
124
129
  const qs = new URLSearchParams({ adjustEstimate: 'leave' });
125
130
  const path = `/rest/api/2/issue/${encodeURIComponent(issueKey)}/worklog?${qs}`;
126
- return normalizeJiraWorklog(await jiraApiFetch(token, path, {
131
+ const data = await jiraApiFetch(token, path, {
127
132
  ...options,
128
133
  method: 'POST',
129
134
  retries: 0,
130
135
  body: worklog
131
- }));
136
+ });
137
+ return isJiraDryRunResult(data) ? data : normalizeJiraWorklog(data);
138
+ }
139
+
140
+ export function isJiraDryRunResult(value) {
141
+ return value?.dryRun === true;
142
+ }
143
+
144
+ function createJiraDryRunRequest(method, path, body) {
145
+ const request = {
146
+ dryRun: true,
147
+ method: String(method).toUpperCase(),
148
+ path: String(path)
149
+ };
150
+ if (body !== undefined) {
151
+ request.body = structuredClone(body);
152
+ }
153
+ return request;
132
154
  }
133
155
 
134
156
  export function normalizeJiraIssue(issue = {}) {