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
@@ -3,16 +3,34 @@ 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 { normalizeQueryRange, queryLogwork } from './query-workflow.mjs';
6
+ import { findRepoMapping, loadLocalConfig } from './logwork-config.mjs';
7
+ import { projectIdentityKey, sameProjectIdentity } from './project-identity.mjs';
8
+ import { resolveEntryProject } from './project-resolver.mjs';
9
+ import {
10
+ createMembershipProjectsFetcher,
11
+ normalizeQueryRange,
12
+ queryLogwork
13
+ } from './query-workflow.mjs';
14
+ import {
15
+ bookedProjectsByDateFromQuery,
16
+ historyFromQueryResult,
17
+ suggestRepoProjects
18
+ } from './repo-project-suggestions.mjs';
7
19
  import { addLocalDays, todayLocalDateISO } from './util.mjs';
8
20
 
9
21
  // Builds a logwork draft from the user's own git commits:
10
22
  // commits (all branches, this author, in the period)
11
23
  // -> grouped per day by repo + ticket + kind of work
12
24
  // -> 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
15
- // -> a "+<hours> <worklog task> | <description>" block for log_work.
25
+ // -> each group gets an RO project: a mapping whose repos list holds the
26
+ // repo, else the project resolver (tickets, keywords, booked projects)
27
+ // -> each project's remaining hours (RO booked - logged) are split across its
28
+ // groups by how much work each group holds; unresolved groups share the
29
+ // room of booked projects no group claimed (without RO: a standard day)
30
+ // -> a "+<hours> <worklog task> | <description>" block for log_work
31
+ // -> for each repo whose lines got no project, ranked project suggestions
32
+ // (ticket history, repo/remote name, single booked project) so the user
33
+ // can confirm one and map the repo once.
16
34
  // Nothing is written anywhere; the agent shows the draft, the user edits and
17
35
  // approves it, then log_work previews and applies it as usual.
18
36
 
@@ -25,6 +43,7 @@ const CONVENTIONAL_RE = /^(\w+)(?:\([^)]*\))?!?:\s*/;
25
43
  const FIELD = '\x1f';
26
44
  const RECORD = '\x1e';
27
45
  const MAX_DESCRIPTION_LENGTH = 240;
46
+ const SUGGESTION_HISTORY_DAYS = 30;
28
47
 
29
48
  // Default RO catalog task per kind of work. These are the shared default task
30
49
  // names; a project with its own catalog may need a different name (list_tasks).
@@ -115,8 +134,16 @@ export function parseGitLog(output = '') {
115
134
  return commits;
116
135
  }
117
136
 
137
+ export async function resolveGitRepoRoot(path, runGit = runGitCommand) {
138
+ const root = String(await runGit(path, ['rev-parse', '--show-toplevel'])).trim();
139
+ if (!root) {
140
+ throw new Error('git did not report a repository root');
141
+ }
142
+ return resolve(root);
143
+ }
144
+
118
145
  export async function readRepoCommits({ repo, from, to, author, runGit = runGitCommand }) {
119
- const root = (await runGit(repo, ['rev-parse', '--show-toplevel'])).trim();
146
+ const root = await resolveGitRepoRoot(repo, runGit);
120
147
  const authorFilter = (author || (await runGit(root, ['config', 'user.email']).catch(() => ''))).trim();
121
148
  if (!authorFilter) {
122
149
  throw new Error(`No git author for ${root}. Set git config user.email or pass author.`);
@@ -285,31 +312,111 @@ function isWeekend(localDateISO) {
285
312
  }
286
313
 
287
314
  // 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.
315
+ // over-logging one project does not hide a gap on another. projects keeps every
316
+ // booked project with its ids so commit groups can be allocated per project.
289
317
  export function remainingHoursByDay(roResult) {
290
318
  const byDay = new Map();
291
319
  for (const day of roResult?.days || []) {
292
- const projects = day.projects || [];
320
+ const projects = (day.projects || []).map((project) => ({
321
+ ...projectRef(project),
322
+ bookedHours: Number(project.bookedHours || 0),
323
+ loggedHours: Number(project.loggedHours || 0),
324
+ remainingHours: roundHours(Math.max(0, Number(project.bookedHours || 0) - Number(project.loggedHours || 0)))
325
+ }));
293
326
  const remaining = projects.length
294
- ? projects.reduce((sum, project) => sum + Math.max(0, Number(project.bookedHours || 0) - Number(project.loggedHours || 0)), 0)
327
+ ? projects.reduce((sum, project) => sum + project.remainingHours, 0)
295
328
  : Math.max(0, Number(day.bookedHours || 0) - Number(day.loggedHours || 0));
296
329
  byDay.set(day.date, {
297
330
  bookedHours: Number(day.bookedHours || 0),
298
331
  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.
332
+ remainingHours: roundHours(remaining),
333
+ projects,
302
334
  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
335
  .filter((project) => project.remainingHours > 0)
336
+ .map((project) => ({ ...projectRef(project), remainingHours: project.remainingHours }))
308
337
  });
309
338
  }
310
339
  return byDay;
311
340
  }
312
341
 
342
+ // The project of one commit group: a mapping whose repos list holds the repo,
343
+ // else the shared resolver on the description and tickets against the day's
344
+ // booked projects and the ticket/keyword mappings, else null.
345
+ export function resolveGroupProject(group, { bookedProjects = [], config = {}, platform = process.platform } = {}) {
346
+ const mapping = findRepoMapping(config.projectMappings, group.repo, platform);
347
+ if (mapping) {
348
+ const booked = bookedProjects.find((project) => sameProjectIdentity(project, mapping));
349
+ return { ...projectRef(booked || mapping), source: 'repo_mapping' };
350
+ }
351
+ if (!bookedProjects.length) {
352
+ return null;
353
+ }
354
+ const resolution = resolveEntryProject({
355
+ entry: { id: group.repo, text: group.description, taskName: group.description, tickets: group.tickets },
356
+ projects: bookedProjects,
357
+ config
358
+ });
359
+ if (resolution.status !== 'resolved') {
360
+ return null;
361
+ }
362
+ return { ...projectRef(resolution.project), source: 'resolver', reason: resolution.reason };
363
+ }
364
+
365
+ // Splits one day's RO room per project:
366
+ // - groups resolved to a project share that project's remaining hours;
367
+ // - unresolved groups share the remaining hours of booked projects that got
368
+ // no resolved group;
369
+ // - nothing ever exceeds a project's remaining hours.
370
+ export function allocateDayByProject(groups, projects) {
371
+ const hours = new Array(groups.length).fill(0);
372
+ const byProject = new Map();
373
+ const unresolved = [];
374
+ groups.forEach((group, index) => {
375
+ if (!group.project) {
376
+ unresolved.push(index);
377
+ return;
378
+ }
379
+ const key = projectIdentityKey(group.project);
380
+ const entry = byProject.get(key) || { project: group.project, indexes: [] };
381
+ entry.indexes.push(index);
382
+ byProject.set(key, entry);
383
+ });
384
+
385
+ const claimed = new Set();
386
+ const allocation = [];
387
+ for (const { project, indexes } of byProject.values()) {
388
+ const booked = projects.find((candidate) => sameProjectIdentity(candidate, project));
389
+ if (booked) {
390
+ claimed.add(booked);
391
+ }
392
+ const remainingHours = booked?.remainingHours ?? 0;
393
+ const split = allocateHours(indexes.map((index) => groups[index]), remainingHours);
394
+ indexes.forEach((index, position) => { hours[index] = split[position]; });
395
+ allocation.push({
396
+ ...projectRef(project),
397
+ booked: Boolean(booked),
398
+ remainingHours,
399
+ allocatedHours: roundHours(split.reduce((sum, value) => sum + value, 0)),
400
+ groupCount: indexes.length
401
+ });
402
+ }
403
+
404
+ const unclaimedHours = roundHours(projects
405
+ .filter((project) => !claimed.has(project))
406
+ .reduce((sum, project) => sum + project.remainingHours, 0));
407
+ if (unresolved.length) {
408
+ const split = allocateHours(unresolved.map((index) => groups[index]), unclaimedHours);
409
+ unresolved.forEach((index, position) => { hours[index] = split[position]; });
410
+ }
411
+ return {
412
+ hours,
413
+ allocation,
414
+ unresolved: unresolved.length
415
+ ? { groupCount: unresolved.length, roomHours: unclaimedHours, allocatedHours: roundHours(unresolved.reduce((sum, index) => sum + hours[index], 0)) }
416
+ : null
417
+ };
418
+ }
419
+
313
420
  export async function draftLogworkFromCommits({
314
421
  period = 'today',
315
422
  from,
@@ -319,7 +426,13 @@ export async function draftLogworkFromCommits({
319
426
  useBookings = true,
320
427
  cwd = process.cwd(),
321
428
  runGit = runGitCommand,
322
- queryRo = queryLogwork
429
+ queryRo = queryLogwork,
430
+ loadConfig = loadLocalConfig,
431
+ platform = process.platform,
432
+ suggestProjects = true,
433
+ fetchProjects,
434
+ historyDays = SUGGESTION_HISTORY_DAYS,
435
+ today = todayLocalDateISO()
323
436
  } = {}) {
324
437
  const range = from || to ? normalizeQueryRange({ from, to }) : normalizeQueryRange({ period });
325
438
  const repoPaths = [...new Set((repos?.length ? repos : [cwd]).map((repo) => resolve(cwd, expandHome(String(repo)))))];
@@ -360,14 +473,20 @@ export async function draftLogworkFromCommits({
360
473
  }
361
474
  }
362
475
 
476
+ const config = commits.length ? await loadConfig(cwd) : { projectMappings: [] };
477
+
363
478
  const days = [];
364
479
  for (let date = range.from; date < range.to; date = addLocalDays(date, 1)) {
365
480
  const dayCommits = commits.filter((commit) => commit.date === date);
366
481
  if (!dayCommits.length) {
367
482
  continue;
368
483
  }
369
- const groups = groupCommits(dayCommits);
370
484
  const booking = bookings?.get(date);
485
+ const bookedProjects = (booking?.projects || []).filter((project) => project.bookedHours > 0);
486
+ const groups = groupCommits(dayCommits).map((group) => ({
487
+ ...group,
488
+ project: resolveGroupProject(group, { bookedProjects, config, platform })
489
+ }));
371
490
  let targetHours;
372
491
  let hoursSource;
373
492
  if (booking) {
@@ -381,12 +500,19 @@ export async function draftLogworkFromCommits({
381
500
  targetHours = isWeekend(date) ? 0 : (CONFIG.standardDayHours || 8);
382
501
  hoursSource = isWeekend(date) ? 'weekend' : 'standard_day';
383
502
  }
384
- const hours = allocateHours(groups, targetHours);
503
+ // Per-project split only when RO returned identifiable per-project rows
504
+ // for the day; otherwise (RO unavailable, no booking, day-level totals or
505
+ // nameless rows) the day's target is split across all groups as before.
506
+ const perProject = Boolean(booking?.projects?.length) &&
507
+ booking.projects.every((project) => projectIdentityKey(project) !== '');
508
+ const split = perProject ? allocateDayByProject(groups, booking.projects) : null;
509
+ const hours = split ? split.hours : allocateHours(groups, targetHours);
385
510
  days.push({
386
511
  date,
387
512
  targetHours,
388
513
  hoursSource,
389
514
  ...(booking ? { bookedHours: booking.bookedHours, loggedHours: booking.loggedHours, remainingByProject: booking.remainingByProject } : {}),
515
+ ...(split ? { projectAllocation: split.allocation, unresolvedAllocation: split.unresolved } : {}),
390
516
  groups: groups.map((group, index) => ({
391
517
  hours: hours[index],
392
518
  task: group.task,
@@ -394,6 +520,8 @@ export async function draftLogworkFromCommits({
394
520
  description: group.description,
395
521
  tickets: group.tickets,
396
522
  repo: group.repoName,
523
+ repoPath: group.repo,
524
+ project: group.project,
397
525
  commitCount: group.commitCount,
398
526
  linesChanged: group.linesChanged,
399
527
  commits: group.hashes
@@ -401,15 +529,51 @@ export async function draftLogworkFromCommits({
401
529
  });
402
530
  }
403
531
 
532
+ // log_work preview entry ids are "<date>-<NN>", NN counting entry lines
533
+ // through the whole text (lib/batch-parser.mjs), so they are numbered here in
534
+ // the same order the lines are written.
535
+ const projectOverrides = {};
536
+ let lineIndex = 0;
404
537
  const draftText = days
405
538
  .map((day) => {
406
- const lines = day.groups.filter((group) => group.hours > 0)
407
- .map((group) => `+${group.hours} ${group.task} | ${group.description}`);
539
+ const lines = [];
540
+ for (const group of day.groups.filter((item) => item.hours > 0)) {
541
+ lineIndex += 1;
542
+ const entryId = `${day.date}-${String(lineIndex).padStart(2, '0')}`;
543
+ group.entryId = entryId;
544
+ if (hasValue(group.project?.projectMemberId)) {
545
+ projectOverrides[entryId] = group.project.projectMemberId;
546
+ }
547
+ lines.push(`+${group.hours} ${group.task} | ${group.description}`);
548
+ }
408
549
  return lines.length ? [formatDayHeading(day.date), ...lines].join('\n') : '';
409
550
  })
410
551
  .filter(Boolean)
411
552
  .join('\n\n');
412
553
 
554
+ const unmappedRepos = [...new Set(days.flatMap((day) => day.groups)
555
+ .filter((group) => !group.project)
556
+ .map((group) => group.repoPath))];
557
+
558
+ // Suggestions need RO (candidates and history), so they are skipped when the
559
+ // bookings were not read.
560
+ const suggestions = unmappedRepos.length && bookings && suggestProjects
561
+ ? await buildRepoSuggestions({
562
+ unmappedRepos,
563
+ days,
564
+ bookings,
565
+ queryRo,
566
+ // Memberships come from the same source as the bookings: a caller that
567
+ // injects queryRo (tests, other workflows) gets none unless it injects
568
+ // fetchProjects too, so it never reaches the real RO.
569
+ fetchProjects: fetchProjects || (queryRo === queryLogwork ? createMembershipProjectsFetcher() : async () => []),
570
+ runGit,
571
+ cwd,
572
+ today,
573
+ historyDays
574
+ })
575
+ : { repoSuggestions: {}, projectCandidates: [] };
576
+
413
577
  const result = {
414
578
  status: !commits.length ? 'no_commits' : (draftText ? 'draft' : 'no_hours'),
415
579
  range,
@@ -418,7 +582,11 @@ export async function draftLogworkFromCommits({
418
582
  ...(bookingsError ? { bookingsError } : {}),
419
583
  commitCount: commits.length,
420
584
  days,
421
- draftText
585
+ draftText,
586
+ projectOverrides,
587
+ unmappedRepos,
588
+ repoSuggestions: suggestions.repoSuggestions,
589
+ projectCandidates: suggestions.projectCandidates
422
590
  };
423
591
  result.summary = formatCommitDraftSummary(result);
424
592
  return result;
@@ -446,18 +614,172 @@ export function formatCommitDraftSummary(result) {
446
614
  }[day.hoursSource];
447
615
  lines.push('', `${formatDayHeading(day.date)} - ${source}`);
448
616
  if (day.remainingByProject?.length) {
449
- lines.push(` Room by project: ${day.remainingByProject.map((project) => `${project.projectName} ${project.remainingHours}h`).join(', ')}`);
617
+ lines.push(` Room by project: ${day.remainingByProject.map((project) => `${projectLabel(project)} ${project.remainingHours}h`).join(', ')}`);
618
+ }
619
+ for (const project of day.projectAllocation || []) {
620
+ if (project.allocatedHours === 0) {
621
+ lines.push(` No room on ${projectLabel(project)}${project.booked ? ` (remaining ${project.remainingHours}h)` : ' (not booked this day)'}: its ${project.groupCount} line(s) get 0h.`);
622
+ }
623
+ }
624
+ if (day.unresolvedAllocation) {
625
+ lines.push(` ${day.unresolvedAllocation.groupCount} line(s) without a project share ${day.unresolvedAllocation.roomHours}h left on booked projects no other line claimed.`);
450
626
  }
451
627
  for (const group of day.groups) {
452
- lines.push(` +${group.hours} ${group.task} | ${group.description} [${group.repo}, ${group.commitCount} commit(s)]`);
628
+ const project = group.project
629
+ ? `${projectLabel(group.project)} (${group.project.source === 'repo_mapping' ? 'repo mapping' : group.project.reason || group.project.source})`
630
+ : 'project unresolved';
631
+ lines.push(` ${group.entryId ? `${group.entryId} ` : ''}+${group.hours} ${group.task} | ${group.description} [${group.repo}, ${group.commitCount} commit(s)] -> ${project}`);
453
632
  }
454
633
  }
455
634
  if (result.draftText) {
456
635
  lines.push('', 'Draft for log_work (review with the user first; worklog task names must exist in list_tasks for the project):', '', result.draftText);
636
+ if (Object.keys(result.projectOverrides || {}).length) {
637
+ lines.push('', `Pass projectOverrides to log_work with this draftText unchanged (renumbered or edited lines need new ids): ${JSON.stringify(result.projectOverrides)}`);
638
+ }
639
+ }
640
+ if (result.unmappedRepos?.length) {
641
+ lines.push('', 'No project for the lines of these repos. Confirm each repo\'s RO project once (logwork commits asks in a terminal; /map --repo in the terminal UI; agents call map_project with projectName and repos):');
642
+ for (const repo of result.unmappedRepos) {
643
+ const top = result.repoSuggestions?.[repo]?.[0];
644
+ lines.push(top
645
+ ? `- ${repo}: likely ${projectLabel(top)} (${top.reasons.join('; ')})`
646
+ : `- ${repo}: no suggestion; ask which project it belongs to`);
647
+ }
457
648
  }
458
649
  return lines.join('\n');
459
650
  }
460
651
 
652
+ // Ranked project suggestions per unmapped repo. RO history and memberships
653
+ // are read once per draft and are best effort: an auth error still reaches the
654
+ // caller (authRequired), any other error only drops that evidence.
655
+ async function buildRepoSuggestions({ unmappedRepos, days, bookings, queryRo, fetchProjects, runGit, cwd, today, historyDays }) {
656
+ const historyResult = await bestEffort(() => queryRo({
657
+ from: addLocalDays(today, -historyDays),
658
+ to: addLocalDays(today, 1),
659
+ includeEntries: true,
660
+ cwd
661
+ }));
662
+ const memberships = await bestEffort(fetchProjects);
663
+ const projectCandidates = mergeCandidates([
664
+ ...[...bookings.values()].flatMap((day) => day.projects.filter((project) => project.bookedHours > 0)),
665
+ ...(Array.isArray(memberships) ? memberships : [])
666
+ ]);
667
+ const history = historyFromQueryResult(historyResult);
668
+ const bookedProjectsByDate = {};
669
+ for (const [date, day] of bookings) {
670
+ bookedProjectsByDate[date] = day.projects.filter((project) => project.bookedHours > 0);
671
+ }
672
+
673
+ const repoSuggestions = {};
674
+ for (const repo of unmappedRepos) {
675
+ const repoGroups = days.flatMap((day) => day.groups
676
+ .filter((group) => group.repoPath === repo && !group.project)
677
+ .map((group) => ({ ...group, date: day.date })));
678
+ repoSuggestions[repo] = suggestRepoProjects({
679
+ repoPath: repo,
680
+ repoGroups,
681
+ candidates: projectCandidates,
682
+ history,
683
+ remoteUrl: await readRemoteUrl(repo, runGit),
684
+ bookedProjectsByDate
685
+ });
686
+ }
687
+ return { repoSuggestions, projectCandidates };
688
+ }
689
+
690
+ // Suggestions for one repo outside a draft (the terminal UI /map picker): the
691
+ // last historyDays of the repo's commits, RO entries and bookings, ranked
692
+ // against the given member projects.
693
+ export async function suggestProjectsForRepo({
694
+ repo,
695
+ projects = [],
696
+ author,
697
+ cwd = process.cwd(),
698
+ runGit = runGitCommand,
699
+ queryRo = queryLogwork,
700
+ historyDays = SUGGESTION_HISTORY_DAYS,
701
+ today = todayLocalDateISO()
702
+ } = {}) {
703
+ const repoPath = resolve(cwd, expandHome(String(repo || '.')));
704
+ const root = await resolveGitRepoRoot(repoPath, runGit);
705
+ const from = addLocalDays(today, -historyDays);
706
+ const to = addLocalDays(today, 1);
707
+ const commits = await readRepoCommits({ repo: root, from, to, author, runGit })
708
+ .then((result) => result.commits)
709
+ .catch(() => []);
710
+ const repoGroups = [...new Set(commits.map((commit) => commit.date))]
711
+ .flatMap((date) => groupCommits(commits.filter((commit) => commit.date === date)).map((group) => ({ ...group, date })));
712
+ const historyResult = await bestEffort(() => queryRo({ from, to, includeEntries: true, cwd }));
713
+ const candidates = mergeCandidates(projects);
714
+ return {
715
+ repoRoot: root,
716
+ candidates,
717
+ suggestions: suggestRepoProjects({
718
+ repoPath: root,
719
+ repoGroups,
720
+ candidates,
721
+ history: historyFromQueryResult(historyResult),
722
+ remoteUrl: await readRemoteUrl(root, runGit),
723
+ bookedProjectsByDate: bookedProjectsByDateFromQuery(historyResult)
724
+ })
725
+ };
726
+ }
727
+
728
+ export async function readRemoteUrl(repo, runGit = runGitCommand) {
729
+ try {
730
+ return String(await runGit(repo, ['remote', 'get-url', 'origin'])).trim();
731
+ } catch {
732
+ return '';
733
+ }
734
+ }
735
+
736
+ async function bestEffort(action) {
737
+ try {
738
+ return await action();
739
+ } catch (error) {
740
+ if (isAuthRequiredError(error)) {
741
+ throw error;
742
+ }
743
+ return null;
744
+ }
745
+ }
746
+
747
+ function mergeCandidates(projects) {
748
+ const results = [];
749
+ for (const project of projects) {
750
+ if (!project || !(project.projectName || hasValue(project.projectMemberId))) {
751
+ continue;
752
+ }
753
+ const known = results.findIndex((candidate) => sameProjectIdentity(candidate, project));
754
+ if (known < 0) {
755
+ results.push(projectRef(project));
756
+ } else {
757
+ results[known] = { ...projectRef(project), ...results[known] };
758
+ }
759
+ }
760
+ return results;
761
+ }
762
+
763
+ function projectRef(project = {}) {
764
+ return {
765
+ projectName: project.projectName || '',
766
+ ...(hasValue(project.projectMemberId) ? { projectMemberId: project.projectMemberId } : {}),
767
+ ...(hasValue(project.projectId) ? { projectId: project.projectId } : {})
768
+ };
769
+ }
770
+
771
+ function projectLabel(project = {}) {
772
+ return project.projectName || (hasValue(project.projectMemberId) ? `project member ${project.projectMemberId}` : 'unnamed project');
773
+ }
774
+
775
+ function hasValue(value) {
776
+ return value !== undefined && value !== null && String(value).trim() !== '';
777
+ }
778
+
779
+ function roundHours(value) {
780
+ return Number(Number(value || 0).toFixed(2));
781
+ }
782
+
461
783
  function expandHome(path) {
462
784
  return path === '~' || /^~[\\/]/.test(path) ? `${homedir()}${path.slice(1)}` : path;
463
785
  }
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,11 @@ 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 { checkLaunchCommand, formatLaunchCheckIssue, resolveLaunchCommand } from './launcher.mjs';
14
+ import { createMcpInstallContext, inspectMcpClients } from './mcp-install.mjs';
15
+ import { formatNodePathIssue, inspectNodePath, inspectScheduledCommand, loadRuntimeInfo } from './node-runtime.mjs';
16
+ import { helperHome, manualDraftsPath, runtimeInfoPath, userConfigPath } from './paths.mjs';
17
+ import { readReminderSchedulerNodePath } from './reminder-scheduler.mjs';
15
18
 
16
19
  const DIAGNOSTICS_DIR_NAME = 'diagnostics';
17
20
  const MAX_AUTH_DEBUG_CHARS = 20_000;
@@ -35,6 +38,8 @@ export async function generateDiagnosticsReport({
35
38
  commandFinder = findCommandOnPath,
36
39
  authStatusFn = getStoredAuthStatus,
37
40
  mcpSmokeFn = runMcpSmoke,
41
+ nodeRuntimeFn = collectNodeRuntimeCheck,
42
+ mcpClientsFn = collectMcpClientCheck,
38
43
  readFileFn = readFile,
39
44
  statFn = stat,
40
45
  writeFileFn = writeFile,
@@ -47,6 +52,13 @@ export async function generateDiagnosticsReport({
47
52
  const fileChecks = await collectFileChecks({ statFn, cwd });
48
53
  const configSnapshot = await safeCollect('config snapshot', () => collectConfigSnapshot({ cwd, includeMappingDetails }));
49
54
  const mcpSmoke = await safeCollect('mcp smoke', () => mcpSmokeFn({ cwd }));
55
+ const nodeRuntime = await safeCollect('node runtime', () => nodeRuntimeFn());
56
+ const mcpClients = await safeCollect('mcp clients', () => mcpClientsFn());
57
+ const runtimeIssues = Array.isArray(nodeRuntime?.issues) ? nodeRuntime.issues : [];
58
+ const mcpIssues = (Array.isArray(mcpClients) ? mcpClients : [])
59
+ .filter((client) => client.status === 'stale' || client.legacy || client.localScopes)
60
+ .map((client) => `MCP: ${formatMcpClientCheck(client)}. Fix: run \`logwork mcp install\`.`);
61
+ const nodeIssues = [...runtimeIssues, ...mcpIssues];
50
62
  const authDebug = await readSanitizedAuthDebug({ readFileFn });
51
63
 
52
64
  sections.push(formatSection('logwork-cli Diagnostics', [
@@ -64,6 +76,30 @@ export async function generateDiagnosticsReport({
64
76
  `home: ${homedir()}`
65
77
  ]));
66
78
 
79
+ sections.push(formatSection('Node Path', nodeRuntime?.ok === false
80
+ ? formatObjectLines(nodeRuntime)
81
+ : [
82
+ `runtimeFile: ${nodeRuntime.runtimeFile}`,
83
+ ...(nodeRuntime.launchCommand ? [
84
+ `launchCommand: ${nodeRuntime.launchCommand}`,
85
+ `launchStatus: ${nodeRuntime.launchStatus}`,
86
+ `launchNode: ${nodeRuntime.launchNode || 'unknown'}`,
87
+ `launchVersion: ${nodeRuntime.launchVersion || 'unknown'}`
88
+ ] : []),
89
+ `recordedNode: ${nodeRuntime.recordedNode || 'none (run logwork setup)'}${nodeRuntime.recordedVersion ? ` (${nodeRuntime.recordedVersion})` : ''}`,
90
+ `recordedNodeStatus: ${nodeRuntime.recordedStatus}${nodeRuntime.recordedNote ? ` (${nodeRuntime.recordedNote})` : ''}`,
91
+ `reminderNode: ${nodeRuntime.reminderNode || 'none (no reminder schedule)'}`,
92
+ `reminderNodeStatus: ${nodeRuntime.reminderStatus}`,
93
+ ...(runtimeIssues.length ? runtimeIssues.map((issue) => `warning: ${issue}`) : ['warnings: none'])
94
+ ]));
95
+
96
+ sections.push(formatSection('MCP Clients', Array.isArray(mcpClients)
97
+ ? [
98
+ ...(mcpClients.length ? mcpClients.map(formatMcpClientCheck) : ['none detected']),
99
+ ...mcpIssues.map((issue) => `warning: ${issue}`)
100
+ ]
101
+ : formatObjectLines(mcpClients)));
102
+
67
103
  sections.push(formatSection('Commands', commandChecks.map((entry) => `${entry.command}: ${entry.available ? 'found' : 'missing'}${entry.path ? ` (${entry.path})` : ''}`)));
68
104
  sections.push(formatSection('Files', fileChecks.map((entry) => formatFileCheck(entry))));
69
105
  sections.push(formatSection('Config Snapshot', formatObjectLines(configSnapshot)));
@@ -78,10 +114,86 @@ export async function generateDiagnosticsReport({
78
114
  return {
79
115
  path: outputPath,
80
116
  report,
81
- summary: `Diagnostics report written to ${outputPath}`
117
+ nodeIssues: nodeIssues.map((issue) => sanitizeReport(issue)),
118
+ summary: [
119
+ ...nodeIssues.map((issue) => sanitizeReport(`Warning: ${issue}`)),
120
+ `Diagnostics report written to ${outputPath}`
121
+ ].join(EOL)
122
+ };
123
+ }
124
+
125
+ // Checks the launch command IDE configs and the reminder run (it must exist
126
+ // and start a usable Node), the node recorded by `logwork setup`
127
+ // (runtime.json) and the command in the installed reminder schedule.
128
+ // Read-only: it only runs the launch command with --version.
129
+ export async function collectNodeRuntimeCheck({
130
+ launch = resolveLaunchCommand(),
131
+ loadRuntime = loadRuntimeInfo,
132
+ readSchedulerNodePath = readReminderSchedulerNodePath,
133
+ inspect = inspectNodePath,
134
+ inspectScheduled = (path) => inspectScheduledCommand(path, { launch }),
135
+ checkLaunch = () => checkLaunchCommand(launch)
136
+ } = {}) {
137
+ const runtime = await loadRuntime().catch(() => null);
138
+ const reminderNode = await readSchedulerNodePath().catch(() => null);
139
+ const launchCheck = await checkLaunch().catch((error) => ({ status: 'failed', command: launch.command, kind: launch.kind, detail: error.message }));
140
+ const recorded = runtime?.nodePath
141
+ ? await inspect(runtime.nodePath)
142
+ : { status: 'unknown' };
143
+ const reminder = reminderNode
144
+ ? await inspectScheduled(reminderNode)
145
+ : { status: 'unknown' };
146
+ // With the launcher, a gone or switched recorded node is not fatal: the
147
+ // launcher falls back to another Node 22+.
148
+ const launcherCovers = launch.kind === 'launcher' && launchCheck?.status === 'ok';
149
+ const recordedNote = launcherCovers && (recorded.status === 'missing' || recorded.status === 'stale')
150
+ ? `the launcher uses ${launchCheck.node || 'another Node'}; run \`logwork setup\` to record it`
151
+ : '';
152
+ const issues = [
153
+ formatLaunchCheckIssue(launchCheck),
154
+ launcherCovers ? '' : formatNodePathIssue('runtime', recorded),
155
+ formatNodePathIssue('reminder', reminder)
156
+ ].filter(Boolean);
157
+ return {
158
+ runtimeFile: runtimeInfoPath(),
159
+ launchCommand: [launch.command, ...launch.argsPrefix].join(' '),
160
+ launchStatus: launchCheck?.status || 'unknown',
161
+ launchNode: launchCheck?.node || '',
162
+ launchVersion: launchCheck?.version || '',
163
+ recordedNode: runtime?.nodePath || '',
164
+ recordedVersion: runtime?.nodeVersion || '',
165
+ recordedStatus: recorded.status,
166
+ recordedNote,
167
+ reminderNode: reminderNode || '',
168
+ reminderStatus: reminder.status,
169
+ issues
82
170
  };
83
171
  }
84
172
 
173
+ // The `logwork` entry of each MCP client config (what `logwork mcp status`
174
+ // shows). Read-only.
175
+ export async function collectMcpClientCheck({
176
+ ctx = createMcpInstallContext()
177
+ } = {}) {
178
+ const clients = await inspectMcpClients(ctx);
179
+ return clients.filter((client) => client.detected).map((client) => ({
180
+ label: client.label,
181
+ status: client.status,
182
+ reason: client.reason,
183
+ legacy: client.legacy,
184
+ localScopes: (client.localScopes || []).length
185
+ }));
186
+ }
187
+
188
+ function formatMcpClientCheck(client) {
189
+ const extra = [
190
+ client.reason,
191
+ client.legacy ? 'leftover logwork-helper entry' : '',
192
+ client.localScopes ? `${client.localScopes} stale local-scope entries` : ''
193
+ ].filter(Boolean).join('; ');
194
+ return `${client.label}: ${client.status}${extra ? ` (${extra})` : ''}`;
195
+ }
196
+
85
197
  async function readPackageInfo({ readFileFn }) {
86
198
  try {
87
199
  const packagePath = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
@@ -202,7 +314,7 @@ async function safeCollect(label, fn) {
202
314
  }
203
315
  }
204
316
 
205
- export async function runMcpSmoke({ cwd = process.cwd(), timeoutMs = MCP_SMOKE_TIMEOUT_MS } = {}) {
317
+ export async function runMcpSmoke({ timeoutMs = MCP_SMOKE_TIMEOUT_MS } = {}) {
206
318
  const client = new Client({
207
319
  name: 'logwork-diagnostics',
208
320
  version: '0.1.0'