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.
- package/README.md +53 -25
- package/RELEASE.md +4 -2
- package/bin/logwork.mjs +2 -2
- package/commands/commits.mjs +128 -13
- package/commands/history.mjs +0 -4
- package/commands/mcp.mjs +266 -0
- package/commands/quick.mjs +38 -23
- package/commands/reminder.mjs +41 -2
- package/commands/setup.mjs +197 -42
- package/commands/tui.mjs +1 -0
- package/docs/advanced.md +18 -7
- package/docs/mcp-setup.md +120 -26
- package/docs/operations-guide.md +50 -2
- package/docs/recipes.md +40 -0
- package/docs/security.md +23 -2
- package/examples/mcp/antigravity.mcp_config.json +2 -2
- package/examples/mcp/codex.config.toml +2 -2
- package/examples/mcp/copilot.mcp.json +2 -2
- package/lib/api-auth-forms.mjs +393 -0
- package/lib/api-auth-http.mjs +184 -0
- package/lib/api-auth-support.mjs +101 -0
- package/lib/api-auth-tokens.mjs +129 -0
- package/lib/api-auth.mjs +39 -813
- package/lib/api.mjs +38 -1422
- package/lib/apply-ledger.mjs +1 -1
- package/lib/batch-workflow.mjs +187 -2
- package/lib/commit-workflow.mjs +345 -23
- package/lib/config.mjs +7 -0
- package/lib/diagnostics.mjs +116 -4
- package/lib/jira-api.mjs +25 -3
- package/lib/jira-workflow.mjs +36 -6
- package/lib/launcher.mjs +303 -0
- package/lib/logwork-config.mjs +64 -13
- package/lib/manual-apply-state.mjs +15 -0
- package/lib/manual-commands.mjs +2 -2
- package/lib/manual-ink-app.mjs +142 -1282
- package/lib/manual-ink-draft-actions.mjs +227 -0
- package/lib/manual-ink-interaction.mjs +318 -0
- package/lib/manual-ink-session.mjs +41 -0
- package/lib/manual-ink-task-editor.mjs +338 -0
- package/lib/manual-ink-ui.mjs +38 -0
- package/lib/manual-ink-wizard.mjs +520 -0
- package/lib/manual-mcp.mjs +78 -20
- package/lib/manual-repl.mjs +171 -22
- package/lib/mcp-install.mjs +930 -0
- package/lib/mcp-server.mjs +51 -20
- package/lib/node-runtime.mjs +155 -0
- package/lib/paths.mjs +7 -0
- package/lib/project-mapping-workflow.mjs +32 -27
- package/lib/query-workflow.mjs +19 -1
- package/lib/reconciliation-workflow.mjs +1 -1
- package/lib/reminder-config.mjs +38 -2
- package/lib/reminder-scheduler.mjs +85 -18
- package/lib/reminder-service.mjs +42 -3
- package/lib/reminder-workflow.mjs +118 -22
- package/lib/repo-project-suggestions.mjs +285 -0
- package/lib/ro-api-diagnostics.mjs +109 -0
- package/lib/ro-api-entries.mjs +359 -0
- package/lib/ro-api-fields.mjs +290 -0
- package/lib/ro-api-http.mjs +68 -0
- package/lib/ro-api-projects.mjs +137 -0
- package/lib/ro-api-tasks.mjs +50 -0
- package/lib/ro-api-timesheet.mjs +413 -0
- package/lib/ro-delete-workflow.mjs +2 -1
- package/lib/ro-entry-reader.mjs +11 -6
- package/lib/ro-resubmit-workflow.mjs +2 -1
- package/lib/terminal-auth-prompts.mjs +7 -4
- package/lib/ui.mjs +121 -56
- package/package.json +11 -4
- package/skills/logwork/SKILL.md +10 -4
package/lib/commit-workflow.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
// ->
|
|
14
|
-
//
|
|
15
|
-
// ->
|
|
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 =
|
|
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 +
|
|
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:
|
|
300
|
-
|
|
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
|
-
|
|
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 =
|
|
407
|
-
|
|
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
|
|
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
|
-
|
|
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 = {
|
package/lib/diagnostics.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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({
|
|
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'
|