logwork-cli 1.2.0 → 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 +37 -25
- package/RELEASE.md +3 -2
- package/bin/logwork.mjs +2 -2
- package/commands/commits.mjs +78 -14
- package/commands/mcp.mjs +266 -0
- package/commands/setup.mjs +161 -40
- package/docs/advanced.md +7 -6
- package/docs/mcp-setup.md +80 -28
- package/docs/operations-guide.md +18 -3
- package/docs/recipes.md +18 -5
- package/docs/security.md +20 -1
- 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/commit-workflow.mjs +160 -5
- package/lib/diagnostics.mjs +72 -9
- package/lib/launcher.mjs +303 -0
- package/lib/manual-commands.mjs +2 -2
- package/lib/manual-ink-app.mjs +34 -1
- package/lib/manual-ink-interaction.mjs +12 -0
- package/lib/manual-ink-ui.mjs +38 -0
- package/lib/manual-mcp.mjs +68 -22
- package/lib/manual-repl.mjs +147 -34
- package/lib/mcp-install.mjs +930 -0
- package/lib/mcp-server.mjs +2 -2
- package/lib/node-runtime.mjs +52 -12
- package/lib/project-mapping-workflow.mjs +2 -20
- package/lib/query-workflow.mjs +19 -0
- package/lib/reminder-scheduler.mjs +34 -18
- package/lib/reminder-service.mjs +18 -4
- package/lib/repo-project-suggestions.mjs +285 -0
- package/lib/ui.mjs +44 -0
- package/package.json +1 -1
- package/skills/logwork/SKILL.md +6 -1
package/lib/mcp-server.mjs
CHANGED
|
@@ -135,7 +135,7 @@ server.registerTool('list_tasks', {
|
|
|
135
135
|
|
|
136
136
|
server.registerTool('draft_from_commits', {
|
|
137
137
|
title: 'Draft logwork from git commits',
|
|
138
|
-
description: 'Read-only: read the user\'s own git commits (all branches, git user.email) in one or more repos for a period, group them per day by repo + ticket + kind of work, suggest an RO worklog task per group (feat -> Feature Development, fix -> Bug Fix, refactor/review -> Code Review & Refactoring, test -> Testing Activities, ci/build/release -> Deployment & Release, docs -> Requirements & Design), and resolve each group\'s RO project (a map_project mapping whose repos holds the repo, else ticket/keyword mappings and that day\'s bookings), and split each project\'s remaining RO hours (booked - logged) across its groups; groups without a project share the room of booked projects no group claimed. Returns draftText in the log_work line format plus projectOverrides (preview entry id -> projectMemberId) for every line with a project. Review it with the user, check task names with list_tasks, then pass draftText and projectOverrides unchanged to log_work. unmappedRepos lists repos whose lines got no project
|
|
138
|
+
description: 'Read-only: read the user\'s own git commits (all branches, git user.email) in one or more repos for a period, group them per day by repo + ticket + kind of work, suggest an RO worklog task per group (feat -> Feature Development, fix -> Bug Fix, refactor/review -> Code Review & Refactoring, test -> Testing Activities, ci/build/release -> Deployment & Release, docs -> Requirements & Design), and resolve each group\'s RO project (a map_project mapping whose repos holds the repo, else ticket/keyword mappings and that day\'s bookings), and split each project\'s remaining RO hours (booked - logged) across its groups; groups without a project share the room of booked projects no group claimed. Returns draftText in the log_work line format plus projectOverrides (preview entry id -> projectMemberId) for every line with a project. Review it with the user, check task names with list_tasks, then pass draftText and projectOverrides unchanged to log_work. unmappedRepos lists repos whose lines got no project; repoSuggestions maps each of them to up to 5 ranked projects ({ projectName, projectMemberId, projectId, score, reasons }) from ticket history, repo/remote name and single booked project, and projectCandidates lists every member project. For each unmapped repo ask the user one short question naming the top suggestion ("Is <repo> part of <projectName>?"), then call map_project with projectName (or projectMemberId) and repos (confirm: true after approval), and run draft_from_commits again. Never ask the user for ids.',
|
|
139
139
|
annotations: READ_ONLY,
|
|
140
140
|
inputSchema: {
|
|
141
141
|
period: z.enum(PERIOD_VALUES).optional().describe('Preset period. Defaults to today.'),
|
|
@@ -348,7 +348,7 @@ server.registerTool('login', {
|
|
|
348
348
|
|
|
349
349
|
server.registerTool('reminder', {
|
|
350
350
|
title: 'Logwork reminder',
|
|
351
|
-
description: 'The native macOS/Windows weekday logwork reminder. action "status" is read-only. "enable", "disable" and "test" (shows a sample notification) change the OS scheduler or display a notification and require confirm: true after explicit user approval. When RO hours are missing, the notification also counts today\'s commits in the configured repos. status warns when the
|
|
351
|
+
description: 'The native macOS/Windows weekday logwork reminder. action "status" is read-only. "enable", "disable" and "test" (shows a sample notification) change the OS scheduler or display a notification and require confirm: true after explicit user approval. When RO hours are missing, the notification also counts today\'s commits in the configured repos. status warns when the command in the schedule (the logwork launcher, or node.exe on Windows) is missing or outdated. No credentials are stored in the schedule.',
|
|
352
352
|
annotations: LOCAL_WRITE,
|
|
353
353
|
inputSchema: {
|
|
354
354
|
action: z.enum(['status', 'enable', 'disable', 'test']).describe('status: show config and scheduler state. enable/disable: install or remove the schedule. test: send a sample notification.'),
|
package/lib/node-runtime.mjs
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { basename } from 'node:path';
|
|
2
3
|
import { atomicWriteFile } from './atomic-file.mjs';
|
|
4
|
+
import { LAUNCHER_FILE_NAME, resolveLaunchCommand } from './launcher.mjs';
|
|
3
5
|
import { runtimeInfoPath } from './paths.mjs';
|
|
4
6
|
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// the
|
|
9
|
-
// and `logwork reminder status`
|
|
7
|
+
// `logwork setup` records the node it ran with here. On macOS/Linux the
|
|
8
|
+
// generated launcher (lib/launcher.mjs) tries that node first and falls back
|
|
9
|
+
// to another Node 22+, so IDE configs and the reminder survive upgrades. On
|
|
10
|
+
// Windows the configs and the task store a node.exe path, which `logwork
|
|
11
|
+
// doctor` and `logwork reminder status` compare with this record.
|
|
10
12
|
|
|
11
13
|
export async function writeRuntimeInfo({
|
|
12
14
|
path = runtimeInfoPath(),
|
|
@@ -83,24 +85,62 @@ export async function inspectNodePath(recordedPath, {
|
|
|
83
85
|
return { status: 'stale', recordedPath: recorded, currentPath };
|
|
84
86
|
}
|
|
85
87
|
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
+
// Compares the command an installed OS schedule runs with the current launch
|
|
89
|
+
// command. Statuses:
|
|
90
|
+
// ok: it runs the current launch command (and that command exists).
|
|
91
|
+
// missing: the command (launcher or node) no longer exists.
|
|
92
|
+
// legacy: macOS/Linux schedule from an older install that runs a raw Node
|
|
93
|
+
// path instead of the launcher.
|
|
94
|
+
// stale: it runs another launcher or another node.
|
|
95
|
+
export async function inspectScheduledCommand(commandPath, {
|
|
96
|
+
launch = resolveLaunchCommand(),
|
|
97
|
+
platform = process.platform,
|
|
98
|
+
exists = pathExists,
|
|
99
|
+
inspect = inspectNodePath
|
|
100
|
+
} = {}) {
|
|
101
|
+
const recorded = String(commandPath || '');
|
|
102
|
+
const currentPath = launch.command;
|
|
103
|
+
if (!recorded) {
|
|
104
|
+
return { status: 'unknown', recordedPath: '', currentPath };
|
|
105
|
+
}
|
|
106
|
+
const same = platform === 'win32'
|
|
107
|
+
? recorded.toLowerCase() === String(currentPath).toLowerCase()
|
|
108
|
+
: recorded === currentPath;
|
|
109
|
+
if (same) {
|
|
110
|
+
return { status: await exists(recorded) ? 'ok' : 'missing', recordedPath: recorded, currentPath, kind: launch.kind };
|
|
111
|
+
}
|
|
112
|
+
if (launch.kind === 'launcher') {
|
|
113
|
+
if (basename(recorded) === LAUNCHER_FILE_NAME) {
|
|
114
|
+
return { status: await exists(recorded) ? 'stale' : 'missing', recordedPath: recorded, currentPath, kind: 'launcher' };
|
|
115
|
+
}
|
|
116
|
+
return { status: await exists(recorded) ? 'legacy' : 'missing', recordedPath: recorded, currentPath, kind: 'node' };
|
|
117
|
+
}
|
|
118
|
+
const checked = await inspect(recorded, { currentPath, platform });
|
|
119
|
+
return { ...checked, currentPath, kind: 'node' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// source "runtime": the node recorded by `logwork setup` (Windows MCP configs
|
|
123
|
+
// use it). source "reminder": the command in the installed OS schedule.
|
|
88
124
|
export function formatNodePathIssue(source, inspection) {
|
|
89
|
-
const { status, recordedPath, currentPath } = inspection || {};
|
|
125
|
+
const { status, recordedPath, currentPath, kind } = inspection || {};
|
|
90
126
|
if (source === 'reminder') {
|
|
127
|
+
const what = kind === 'launcher' ? 'the logwork launcher at' : 'Node at';
|
|
91
128
|
if (status === 'missing') {
|
|
92
|
-
return `Reminder: the OS schedule runs
|
|
129
|
+
return `Reminder: the OS schedule runs ${what} ${recordedPath}, which no longer exists, so the reminder cannot run. Fix: run \`logwork reminder enable\` (or \`logwork setup\`) to re-register it.`;
|
|
130
|
+
}
|
|
131
|
+
if (status === 'legacy') {
|
|
132
|
+
return `Reminder: the OS schedule runs Node at ${recordedPath} directly instead of the logwork launcher, so a Node upgrade will break it. Fix: re-run \`logwork setup\` (or \`logwork reminder enable\`).`;
|
|
93
133
|
}
|
|
94
134
|
if (status === 'stale') {
|
|
95
|
-
return `Reminder: the OS schedule runs
|
|
135
|
+
return `Reminder: the OS schedule runs ${what} ${recordedPath}, but the current command is ${currentPath}. Fix: run \`logwork reminder enable\` (or \`logwork setup\`) to re-register it.`;
|
|
96
136
|
}
|
|
97
137
|
return '';
|
|
98
138
|
}
|
|
99
139
|
if (status === 'missing') {
|
|
100
|
-
return `Runtime: \`logwork setup\` recorded Node at ${recordedPath}, which no longer exists, so IDE MCP configs that use it cannot start. Fix: run \`logwork setup
|
|
140
|
+
return `Runtime: \`logwork setup\` recorded Node at ${recordedPath}, which no longer exists, so IDE MCP configs that use it cannot start. Fix: run \`logwork setup\`; it updates the MCP configs it can edit.`;
|
|
101
141
|
}
|
|
102
142
|
if (status === 'stale') {
|
|
103
|
-
return `Runtime: \`logwork setup\` recorded Node at ${recordedPath}, but the current Node is ${currentPath}. Fix: if you switched Node on purpose, run \`logwork setup
|
|
143
|
+
return `Runtime: \`logwork setup\` recorded Node at ${recordedPath}, but the current Node is ${currentPath}. Fix: if you switched Node on purpose, run \`logwork setup\`; it updates the MCP configs it can edit.`;
|
|
104
144
|
}
|
|
105
145
|
return '';
|
|
106
146
|
}
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
|
-
import { ApiError, getProjects } from './api.mjs';
|
|
4
3
|
import { resolveGitRepoRoot } from './commit-workflow.mjs';
|
|
5
4
|
import {
|
|
6
5
|
configPath,
|
|
7
6
|
loadLocalConfig,
|
|
8
7
|
upsertProjectMappingConfig
|
|
9
8
|
} from './logwork-config.mjs';
|
|
10
|
-
import {
|
|
9
|
+
import { createMembershipProjectsFetcher } from './query-workflow.mjs';
|
|
11
10
|
|
|
12
11
|
export async function listLogworkProjects({
|
|
13
12
|
cwd = process.cwd(),
|
|
@@ -167,24 +166,7 @@ export function enrichRoPreviewWithJiraProjects(roPreview, jiraPreview) {
|
|
|
167
166
|
};
|
|
168
167
|
}
|
|
169
168
|
|
|
170
|
-
function
|
|
171
|
-
const session = createResourceOptimiserSession();
|
|
172
|
-
return async () => {
|
|
173
|
-
const { token, userId } = await session.get();
|
|
174
|
-
try {
|
|
175
|
-
return await getProjects(token, userId);
|
|
176
|
-
} catch (error) {
|
|
177
|
-
if (!(error instanceof ApiError) || error.status !== 401) {
|
|
178
|
-
throw error;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const refreshed = await session.get({ forceLogin: true });
|
|
182
|
-
return getProjects(refreshed.token, refreshed.userId);
|
|
183
|
-
}
|
|
184
|
-
};
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function resolveProjectSelection(projects, { projectMemberId, projectName }) {
|
|
169
|
+
export function resolveProjectSelection(projects, { projectMemberId, projectName }) {
|
|
188
170
|
if (projectMemberId !== undefined && projectMemberId !== null && String(projectMemberId).trim() !== '') {
|
|
189
171
|
const project = projects.find((candidate) => String(candidate.projectMemberId) === String(projectMemberId));
|
|
190
172
|
return project
|
package/lib/query-workflow.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
combineNormalizationDiagnostics,
|
|
5
5
|
getDayLogs,
|
|
6
6
|
getLogworkDay,
|
|
7
|
+
getProjects,
|
|
7
8
|
getLogworkRejected,
|
|
8
9
|
getNormalizationDiagnostics,
|
|
9
10
|
getTimesheetRangeResult
|
|
@@ -288,6 +289,24 @@ export function createDayLogsFetcher() {
|
|
|
288
289
|
};
|
|
289
290
|
}
|
|
290
291
|
|
|
292
|
+
// The user's RO project memberships (projectMemberId, projectId, projectName).
|
|
293
|
+
export function createMembershipProjectsFetcher() {
|
|
294
|
+
const session = createResourceOptimiserSession();
|
|
295
|
+
return async () => {
|
|
296
|
+
const { token, userId } = await session.get();
|
|
297
|
+
try {
|
|
298
|
+
return await getProjects(token, userId);
|
|
299
|
+
} catch (error) {
|
|
300
|
+
if (!(error instanceof ApiError) || error.status !== 401) {
|
|
301
|
+
throw error;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const refreshed = await session.get({ forceLogin: true });
|
|
305
|
+
return getProjects(refreshed.token, refreshed.userId);
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
291
310
|
export function createResourceOptimiserSession() {
|
|
292
311
|
let cached = null;
|
|
293
312
|
|
|
@@ -3,6 +3,7 @@ import { promises as fs } from 'node:fs';
|
|
|
3
3
|
import { homedir, userInfo } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { atomicWriteFile } from './atomic-file.mjs';
|
|
6
|
+
import { resolveLaunchCommand, writeLauncher } from './launcher.mjs';
|
|
6
7
|
import { helperHome } from './paths.mjs';
|
|
7
8
|
import { normalizeReminderConfig } from './reminder-config.mjs';
|
|
8
9
|
|
|
@@ -12,18 +13,24 @@ export const WINDOWS_REMINDER_TASK_NAME = 'Logwork Reminder';
|
|
|
12
13
|
// point at the old runtime, so installing or removing the reminder clears them.
|
|
13
14
|
export const LEGACY_MACOS_REMINDER_LABEL = 'sg.vinova.logwork-helper.reminder';
|
|
14
15
|
export const LEGACY_WINDOWS_REMINDER_TASK_NAME = 'Logwork Helper Reminder';
|
|
15
|
-
// The scheduler runs
|
|
16
|
+
// The scheduler runs the launch command from lib/launcher.mjs with
|
|
17
|
+
// `reminder run --home <home>`: on macOS the <home>/bin/logwork-launch script
|
|
18
|
+
// (it finds Node itself), on Windows a stable node.exe plus
|
|
19
|
+
// <home>\bin\logwork.mjs.
|
|
16
20
|
export const REMINDER_RUNNER_RELATIVE_PATH = join('bin', 'logwork.mjs');
|
|
17
21
|
|
|
18
22
|
export async function installReminderScheduler(config, {
|
|
19
23
|
platform = process.platform,
|
|
20
24
|
nodePath = process.execPath,
|
|
21
25
|
home = helperHome(),
|
|
26
|
+
env = process.env,
|
|
27
|
+
launch = resolveLaunchCommand({ platform, home, execPath: nodePath, env }),
|
|
22
28
|
userHome = homedir(),
|
|
23
29
|
uid = userInfo().uid,
|
|
24
30
|
run = runCommand,
|
|
25
31
|
writeFile = atomicWriteFile,
|
|
26
|
-
accessRunner = fs.access
|
|
32
|
+
accessRunner = fs.access,
|
|
33
|
+
writeLauncherFn = writeLauncher
|
|
27
34
|
} = {}) {
|
|
28
35
|
const normalized = normalizeReminderConfig({ ...config, enabled: true });
|
|
29
36
|
const runnerPath = join(home, REMINDER_RUNNER_RELATIVE_PATH);
|
|
@@ -32,14 +39,17 @@ export async function installReminderScheduler(config, {
|
|
|
32
39
|
} catch {
|
|
33
40
|
throw new Error(`Reminder runtime is missing at ${runnerPath}. Run \`logwork setup --no-login\` and retry.`);
|
|
34
41
|
}
|
|
42
|
+
if (launch.kind === 'launcher') {
|
|
43
|
+
// `reminder enable` can run before `logwork setup` wrote the launcher.
|
|
44
|
+
await writeLauncherFn({ home, nodePath, platform });
|
|
45
|
+
}
|
|
35
46
|
await removeLegacyReminderScheduler({ platform, userHome, uid, run });
|
|
36
47
|
|
|
37
48
|
if (platform === 'darwin') {
|
|
38
49
|
const plistPath = macosReminderPlistPath(userHome);
|
|
39
50
|
await writeFile(plistPath, buildMacosReminderPlist({
|
|
40
51
|
config: normalized,
|
|
41
|
-
|
|
42
|
-
runnerPath,
|
|
52
|
+
launch,
|
|
43
53
|
home
|
|
44
54
|
}));
|
|
45
55
|
const domain = `gui/${uid}`;
|
|
@@ -58,8 +68,7 @@ export async function installReminderScheduler(config, {
|
|
|
58
68
|
if (platform === 'win32') {
|
|
59
69
|
const result = await run('schtasks.exe', buildWindowsReminderCreateArgs({
|
|
60
70
|
config: normalized,
|
|
61
|
-
|
|
62
|
-
runnerPath,
|
|
71
|
+
launch,
|
|
63
72
|
home
|
|
64
73
|
}));
|
|
65
74
|
assertCommandSuccess(result, 'schtasks /Create');
|
|
@@ -151,9 +160,10 @@ export async function getReminderSchedulerStatus({
|
|
|
151
160
|
throw unsupportedReminderPlatform(platform);
|
|
152
161
|
}
|
|
153
162
|
|
|
154
|
-
// The
|
|
155
|
-
//
|
|
156
|
-
//
|
|
163
|
+
// The command the installed schedule runs: the first ProgramArguments entry of
|
|
164
|
+
// the launchd plist (the launcher, or a raw node path from an older install),
|
|
165
|
+
// or the <Command> of the Windows task (node.exe). Returns null when no
|
|
166
|
+
// schedule is installed or it cannot be read. Read-only.
|
|
157
167
|
export async function readReminderSchedulerNodePath({
|
|
158
168
|
platform = process.platform,
|
|
159
169
|
userHome = homedir(),
|
|
@@ -190,7 +200,15 @@ export function parseWindowsTaskNodePath(xml) {
|
|
|
190
200
|
return unescapeXml(match[1]).trim().replace(/^"(.*)"$/, '$1') || null;
|
|
191
201
|
}
|
|
192
202
|
|
|
193
|
-
|
|
203
|
+
// `launch` is { command, argsPrefix }. Older callers pass nodePath + runnerPath.
|
|
204
|
+
function scheduleProgram({ launch, nodePath, runnerPath, home }) {
|
|
205
|
+
const program = launch
|
|
206
|
+
? [launch.command, ...(launch.argsPrefix || [])]
|
|
207
|
+
: [nodePath, runnerPath];
|
|
208
|
+
return [...program, 'reminder', 'run', '--home', home];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function buildMacosReminderPlist({ config, launch, nodePath, runnerPath, home }) {
|
|
194
212
|
const normalized = normalizeReminderConfig({ ...config, enabled: true });
|
|
195
213
|
const [hour, minute] = normalized.time.split(':').map(Number);
|
|
196
214
|
const calendarEntries = normalized.days.map((weekday) => ` <dict>
|
|
@@ -198,6 +216,9 @@ export function buildMacosReminderPlist({ config, nodePath, runnerPath, home })
|
|
|
198
216
|
<key>Hour</key><integer>${hour}</integer>
|
|
199
217
|
<key>Minute</key><integer>${minute}</integer>
|
|
200
218
|
</dict>`).join('\n');
|
|
219
|
+
const programArguments = scheduleProgram({ launch, nodePath, runnerPath, home })
|
|
220
|
+
.map((part) => ` <string>${escapeXml(assertSafePath(part))}</string>`)
|
|
221
|
+
.join('\n');
|
|
201
222
|
|
|
202
223
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
203
224
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -206,12 +227,7 @@ export function buildMacosReminderPlist({ config, nodePath, runnerPath, home })
|
|
|
206
227
|
<key>Label</key><string>${escapeXml(MACOS_REMINDER_LABEL)}</string>
|
|
207
228
|
<key>ProgramArguments</key>
|
|
208
229
|
<array>
|
|
209
|
-
|
|
210
|
-
<string>${escapeXml(assertSafePath(runnerPath))}</string>
|
|
211
|
-
<string>reminder</string>
|
|
212
|
-
<string>run</string>
|
|
213
|
-
<string>--home</string>
|
|
214
|
-
<string>${escapeXml(assertSafePath(home))}</string>
|
|
230
|
+
${programArguments}
|
|
215
231
|
</array>
|
|
216
232
|
<key>WorkingDirectory</key><string>${escapeXml(assertSafePath(home))}</string>
|
|
217
233
|
<key>StartCalendarInterval</key>
|
|
@@ -225,9 +241,9 @@ ${calendarEntries}
|
|
|
225
241
|
`;
|
|
226
242
|
}
|
|
227
243
|
|
|
228
|
-
export function buildWindowsReminderCreateArgs({ config, nodePath, runnerPath, home }) {
|
|
244
|
+
export function buildWindowsReminderCreateArgs({ config, launch, nodePath, runnerPath, home }) {
|
|
229
245
|
const normalized = normalizeReminderConfig({ ...config, enabled: true });
|
|
230
|
-
const action =
|
|
246
|
+
const action = scheduleProgram({ launch, nodePath, runnerPath, home })
|
|
231
247
|
.map(quoteWindowsActionPart)
|
|
232
248
|
.join(' ');
|
|
233
249
|
const dayNames = normalized.days.map((day) => ({
|
package/lib/reminder-service.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { checkLaunchCommand, formatLaunchCheckIssue } from './launcher.mjs';
|
|
2
|
+
import { formatNodePathIssue, inspectScheduledCommand } from './node-runtime.mjs';
|
|
2
3
|
import {
|
|
3
4
|
loadReminderConfig,
|
|
4
5
|
normalizeReminderConfig,
|
|
@@ -81,7 +82,8 @@ export async function getLogworkReminderStatus({
|
|
|
81
82
|
loadConfig = loadReminderConfig,
|
|
82
83
|
loadState = loadReminderState,
|
|
83
84
|
schedulerStatus = getReminderSchedulerStatus,
|
|
84
|
-
inspectNode =
|
|
85
|
+
inspectNode = inspectScheduledCommand,
|
|
86
|
+
checkLaunch = checkLaunchCommand
|
|
85
87
|
} = {}) {
|
|
86
88
|
const [config, state, scheduler] = await Promise.all([
|
|
87
89
|
loadConfig(),
|
|
@@ -100,8 +102,9 @@ export async function getLogworkReminderStatus({
|
|
|
100
102
|
if (!consistent) {
|
|
101
103
|
lines.push('Warning: reminder config and OS scheduler are out of sync; run reminder enable or disable again.');
|
|
102
104
|
}
|
|
103
|
-
// The schedule stores an absolute
|
|
104
|
-
//
|
|
105
|
+
// The schedule stores an absolute command: the launcher on macOS (an older
|
|
106
|
+
// install: a raw node path), node.exe on Windows. nvm or Homebrew can remove
|
|
107
|
+
// a node while the schedule still looks installed.
|
|
105
108
|
const nodeCheck = scheduler.nodePath
|
|
106
109
|
? await inspectNode(scheduler.nodePath).catch(() => ({ status: 'unknown', recordedPath: scheduler.nodePath }))
|
|
107
110
|
: null;
|
|
@@ -109,6 +112,16 @@ export async function getLogworkReminderStatus({
|
|
|
109
112
|
if (nodeIssue) {
|
|
110
113
|
lines.push(`Warning: ${nodeIssue}`);
|
|
111
114
|
}
|
|
115
|
+
let launchCheck = null;
|
|
116
|
+
if (nodeCheck?.status === 'ok' && nodeCheck.kind === 'launcher') {
|
|
117
|
+
launchCheck = await checkLaunch().catch(() => null);
|
|
118
|
+
const launchIssue = formatLaunchCheckIssue(launchCheck);
|
|
119
|
+
if (launchIssue) {
|
|
120
|
+
lines.push(`Warning: ${launchIssue}`);
|
|
121
|
+
} else if (launchCheck?.node) {
|
|
122
|
+
lines.push(`Launcher: runs Node ${launchCheck.node}.`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
112
125
|
if (state?.lastRunAt) {
|
|
113
126
|
lines.push(`Last run: ${state.lastRunAt}, status ${state.lastStatus || 'unknown'}.`);
|
|
114
127
|
}
|
|
@@ -119,6 +132,7 @@ export async function getLogworkReminderStatus({
|
|
|
119
132
|
state,
|
|
120
133
|
consistent,
|
|
121
134
|
...(nodeCheck ? { nodeCheck } : {}),
|
|
135
|
+
...(launchCheck ? { launchCheck } : {}),
|
|
122
136
|
summary: lines.join('\n')
|
|
123
137
|
};
|
|
124
138
|
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// Ranks the user's RO projects for a git repo that has no project mapping yet,
|
|
2
|
+
// so the user (or an agent) can confirm the likely project instead of typing a
|
|
3
|
+
// projectMemberId. Pure: every input is passed in.
|
|
4
|
+
//
|
|
5
|
+
// Evidence, strongest first:
|
|
6
|
+
// 1. ticket history: a ticket key from this repo's commits appears in one of
|
|
7
|
+
// the user's own RO entries (description or task) of the last 30 days;
|
|
8
|
+
// 2. name similarity: the repo folder name or git remote matches the project
|
|
9
|
+
// name, its acronym in parentheses, or the initials of its words;
|
|
10
|
+
// 3. single booked project: the repo's commit days had exactly one booked
|
|
11
|
+
// project.
|
|
12
|
+
import { extractTickets } from './commit-workflow.mjs';
|
|
13
|
+
import { projectIdentityKey, sameProjectIdentity } from './project-identity.mjs';
|
|
14
|
+
|
|
15
|
+
export const MAX_REPO_SUGGESTIONS = 5;
|
|
16
|
+
|
|
17
|
+
const SCORE_TICKET = 60;
|
|
18
|
+
const SCORE_TICKET_EXTRA = 5;
|
|
19
|
+
const SCORE_TICKET_MAX = 80;
|
|
20
|
+
const SCORE_ACRONYM = 30;
|
|
21
|
+
const SCORE_INITIALS = 25;
|
|
22
|
+
const SCORE_TOKEN = 15;
|
|
23
|
+
const SCORE_TOKEN_EXTRA = 5;
|
|
24
|
+
const SCORE_TOKEN_MAX = 28;
|
|
25
|
+
const SCORE_SINGLE_BOOKED = 15;
|
|
26
|
+
const MAX_TICKET_REASONS = 3;
|
|
27
|
+
|
|
28
|
+
// Words that say nothing about which project a repo belongs to.
|
|
29
|
+
export const NAME_STOP_WORDS = new Set([
|
|
30
|
+
'app', 'apps', 'web', 'website', 'api', 'apis', 'fe', 'be', 'frontend', 'front', 'backend', 'back', 'end',
|
|
31
|
+
'service', 'services', 'svc', 'mobile', 'the', 'a', 'an', 'of', 'and', 'for', 'project', 'projects', 'prj',
|
|
32
|
+
'sit', 'uat', 'prod', 'dev', 'staging', 'stg', 'team', 'repo', 'git', 'src', 'main', 'client', 'server',
|
|
33
|
+
'android', 'ios', 'ui', 'v1', 'v2', 'v3', 'new', 'old', 'core', 'common',
|
|
34
|
+
// Remote URL noise.
|
|
35
|
+
'github', 'gitlab', 'bitbucket', 'com', 'org', 'net', 'io', 'www', 'http', 'https', 'ssh', 'scm', 'azure',
|
|
36
|
+
'visualstudio', 'vs', 'tfs'
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
// Lowercase tokens of a name: split on -_./ (and any other non-alphanumeric
|
|
40
|
+
// character) and on camelCase / PascalCase boundaries.
|
|
41
|
+
export function tokenizeName(value = '') {
|
|
42
|
+
return String(value)
|
|
43
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
44
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
45
|
+
.split(/[^A-Za-z0-9]+/)
|
|
46
|
+
.map((token) => token.toLowerCase())
|
|
47
|
+
.filter(Boolean);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function meaningfulTokens(value = '') {
|
|
51
|
+
return tokenizeName(value).filter((token) => token.length >= 2 && !NAME_STOP_WORDS.has(token) && !/^\d+$/.test(token));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// What a repo name can match in a project name: its meaningful tokens, the
|
|
55
|
+
// acronyms in parentheses ("GKE Fair Chem System (FCS)" -> fcs), and the
|
|
56
|
+
// initials of its words, with and without stop words ("gfcs").
|
|
57
|
+
export function projectNameKeys(projectName = '') {
|
|
58
|
+
const name = String(projectName);
|
|
59
|
+
const acronyms = [...name.matchAll(/\(([^)]+)\)/g)]
|
|
60
|
+
.flatMap((match) => tokenizeName(match[1]))
|
|
61
|
+
.filter((token) => token.length >= 2);
|
|
62
|
+
const words = tokenizeName(name.replace(/\([^)]*\)/g, ' ')).filter((token) => !/^\d+$/.test(token));
|
|
63
|
+
const initials = new Set();
|
|
64
|
+
for (const list of [words, words.filter((word) => !NAME_STOP_WORDS.has(word))]) {
|
|
65
|
+
if (list.length >= 2) {
|
|
66
|
+
initials.add(list.map((word) => word[0]).join(''));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
tokens: [...new Set(meaningfulTokens(name))],
|
|
71
|
+
acronyms: [...new Set(acronyms)],
|
|
72
|
+
initials: [...initials].filter((value) => value.length >= 2)
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The path part of a git remote ("git@host:org/repo.git",
|
|
77
|
+
// "https://host/org/repo.git", "ssh://git@host:22/org/repo") without the host
|
|
78
|
+
// and the .git suffix.
|
|
79
|
+
export function remoteRepoPath(remoteUrl = '') {
|
|
80
|
+
const text = String(remoteUrl || '').trim();
|
|
81
|
+
if (!text) {
|
|
82
|
+
return '';
|
|
83
|
+
}
|
|
84
|
+
let path = text;
|
|
85
|
+
const scheme = /^[a-z][a-z0-9+.-]*:\/\/[^/]+\/?(.*)$/i.exec(text);
|
|
86
|
+
const scp = /^[^@/\s]+@[^:/\s]+:(.*)$/.exec(text);
|
|
87
|
+
if (scheme) {
|
|
88
|
+
path = scheme[1];
|
|
89
|
+
} else if (scp) {
|
|
90
|
+
path = scp[1];
|
|
91
|
+
}
|
|
92
|
+
return path.replace(/\.git\/?$/i, '').replace(/^\/+|\/+$/g, '');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// RO entries from queryLogwork(includeEntries: true) as history items.
|
|
96
|
+
export function historyFromQueryResult(queryResult) {
|
|
97
|
+
return (Array.isArray(queryResult?.entries) ? queryResult.entries : [])
|
|
98
|
+
.map((entry) => ({
|
|
99
|
+
date: entry.date || '',
|
|
100
|
+
projectName: entry.projectName || '',
|
|
101
|
+
...(hasValue(entry.projectMemberId) ? { projectMemberId: entry.projectMemberId } : {}),
|
|
102
|
+
...(hasValue(entry.projectId) ? { projectId: entry.projectId } : {}),
|
|
103
|
+
text: [entry.taskName, entry.description].filter(Boolean).join(' ')
|
|
104
|
+
}))
|
|
105
|
+
.filter((entry) => entry.text && (entry.projectName || hasValue(entry.projectMemberId) || hasValue(entry.projectId)));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Booked projects (bookedHours > 0) per date from a queryLogwork result.
|
|
109
|
+
export function bookedProjectsByDateFromQuery(queryResult) {
|
|
110
|
+
const byDate = {};
|
|
111
|
+
for (const day of Array.isArray(queryResult?.days) ? queryResult.days : []) {
|
|
112
|
+
byDate[day.date] = (day.projects || []).filter((project) => Number(project.bookedHours || 0) > 0);
|
|
113
|
+
}
|
|
114
|
+
return byDate;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Every candidate with its score and reasons, highest score first; ties keep
|
|
118
|
+
// the candidates' order. A candidate without evidence scores 0.
|
|
119
|
+
export function rankRepoProjects({
|
|
120
|
+
repoPath = '',
|
|
121
|
+
repoGroups = [],
|
|
122
|
+
candidates = [],
|
|
123
|
+
history = [],
|
|
124
|
+
remoteUrl = '',
|
|
125
|
+
bookedProjectsByDate = {}
|
|
126
|
+
} = {}) {
|
|
127
|
+
const projects = uniqueProjects(candidates);
|
|
128
|
+
const tickets = [...new Set(repoGroups.flatMap((group) => group.tickets || []))];
|
|
129
|
+
const repoName = String(repoPath).split(/[\\/]/).filter(Boolean).pop() || '';
|
|
130
|
+
const nameSources = [
|
|
131
|
+
{ label: 'repo name', tokens: meaningfulTokens(repoName) },
|
|
132
|
+
{ label: 'git remote', tokens: meaningfulTokens(remoteRepoPath(remoteUrl)) }
|
|
133
|
+
].filter((source) => source.tokens.length);
|
|
134
|
+
const singleBooked = singleBookedProject(repoGroups, bookedProjectsByDate);
|
|
135
|
+
|
|
136
|
+
const ranked = projects.map((project, index) => {
|
|
137
|
+
const reasons = [];
|
|
138
|
+
let score = 0;
|
|
139
|
+
|
|
140
|
+
const ticketHits = ticketEvidence(project, tickets, history);
|
|
141
|
+
if (ticketHits.length) {
|
|
142
|
+
score += Math.min(SCORE_TICKET_MAX, SCORE_TICKET + SCORE_TICKET_EXTRA * (ticketHits.length - 1));
|
|
143
|
+
reasons.push(...ticketHits.slice(0, MAX_TICKET_REASONS)
|
|
144
|
+
.map((hit) => `ticket ${hit.ticket} was logged to this project on ${hit.date}`));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const name = nameEvidence(project, nameSources);
|
|
148
|
+
if (name) {
|
|
149
|
+
score += name.score;
|
|
150
|
+
reasons.push(name.reason);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (singleBooked && sameProjectIdentity(singleBooked.project, project)) {
|
|
154
|
+
score += SCORE_SINGLE_BOOKED;
|
|
155
|
+
reasons.push(`the only project booked on this repo's commit days (${formatDates(singleBooked.dates)})`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { ...projectRef(project), score, reasons, index };
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return ranked
|
|
162
|
+
.sort((left, right) => right.score - left.score || left.index - right.index)
|
|
163
|
+
.map(({ index: _index, ...item }) => item);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// The top suggestions (score > 0), at most MAX_REPO_SUGGESTIONS.
|
|
167
|
+
export function suggestRepoProjects(input = {}) {
|
|
168
|
+
return rankRepoProjects(input)
|
|
169
|
+
.filter((item) => item.score > 0)
|
|
170
|
+
.slice(0, MAX_REPO_SUGGESTIONS);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Picker order: suggestions first (top one first), then every other candidate
|
|
174
|
+
// in its original order. Each item: { project, suggestion | null }.
|
|
175
|
+
export function orderProjectsForPicker(candidates = [], suggestions = []) {
|
|
176
|
+
const items = suggestions.map((suggestion) => ({ project: projectRef(suggestion), suggestion }));
|
|
177
|
+
for (const project of uniqueProjects(candidates)) {
|
|
178
|
+
if (!items.some((item) => sameProjectIdentity(item.project, project))) {
|
|
179
|
+
items.push({ project: projectRef(project), suggestion: null });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return items;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function formatSuggestionReason(suggestion) {
|
|
186
|
+
return (suggestion?.reasons || []).join('; ');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function ticketEvidence(project, tickets, history) {
|
|
190
|
+
if (!tickets.length || !history.length) {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
const hits = [];
|
|
194
|
+
for (const ticket of tickets) {
|
|
195
|
+
const dates = history
|
|
196
|
+
.filter((entry) => sameProjectIdentity(entry, project) && extractTickets(entry.text).includes(ticket))
|
|
197
|
+
.map((entry) => entry.date)
|
|
198
|
+
.filter(Boolean)
|
|
199
|
+
.sort();
|
|
200
|
+
if (dates.length) {
|
|
201
|
+
hits.push({ ticket, date: dates.at(-1) });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return hits.sort((left, right) => right.date.localeCompare(left.date));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function nameEvidence(project, sources) {
|
|
208
|
+
if (!sources.length) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
const keys = projectNameKeys(project.projectName);
|
|
212
|
+
let best = null;
|
|
213
|
+
const consider = (candidate) => {
|
|
214
|
+
if (!best || candidate.score > best.score) {
|
|
215
|
+
best = candidate;
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
for (const source of sources) {
|
|
219
|
+
const acronym = keys.acronyms.find((value) => source.tokens.includes(value));
|
|
220
|
+
if (acronym) {
|
|
221
|
+
consider({ score: SCORE_ACRONYM, reason: `${source.label} matches ${acronym.toUpperCase()}` });
|
|
222
|
+
}
|
|
223
|
+
const initials = keys.initials.find((value) => source.tokens.includes(value));
|
|
224
|
+
if (initials) {
|
|
225
|
+
consider({ score: SCORE_INITIALS, reason: `${source.label} matches the initials ${initials.toUpperCase()} of the project name` });
|
|
226
|
+
}
|
|
227
|
+
const shared = [...new Set(source.tokens.filter((token) => keys.tokens.includes(token)))];
|
|
228
|
+
if (shared.length) {
|
|
229
|
+
consider({
|
|
230
|
+
score: Math.min(SCORE_TOKEN_MAX, SCORE_TOKEN + SCORE_TOKEN_EXTRA * (shared.length - 1)),
|
|
231
|
+
reason: `${source.label} shares ${shared.map((token) => `"${token}"`).join(', ')} with the project name`
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return best;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function singleBookedProject(repoGroups, bookedProjectsByDate) {
|
|
239
|
+
const dates = [...new Set(repoGroups.map((group) => group.date).filter(Boolean))].sort();
|
|
240
|
+
if (!dates.length) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
const projects = [];
|
|
244
|
+
for (const date of dates) {
|
|
245
|
+
for (const project of bookedProjectsByDate?.[date] || []) {
|
|
246
|
+
if (!projects.some((known) => sameProjectIdentity(known, project))) {
|
|
247
|
+
projects.push(project);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return projects.length === 1 ? { project: projects[0], dates } : null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function uniqueProjects(projects) {
|
|
255
|
+
const seen = new Set();
|
|
256
|
+
const results = [];
|
|
257
|
+
for (const project of Array.isArray(projects) ? projects : []) {
|
|
258
|
+
if (!project || !(project.projectName || hasValue(project.projectMemberId) || hasValue(project.projectId))) {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const key = projectIdentityKey(project);
|
|
262
|
+
if (seen.has(key) || results.some((known) => sameProjectIdentity(known, project))) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
seen.add(key);
|
|
266
|
+
results.push(project);
|
|
267
|
+
}
|
|
268
|
+
return results;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function projectRef(project = {}) {
|
|
272
|
+
return {
|
|
273
|
+
projectName: project.projectName || '',
|
|
274
|
+
...(hasValue(project.projectMemberId) ? { projectMemberId: project.projectMemberId } : {}),
|
|
275
|
+
...(hasValue(project.projectId) ? { projectId: project.projectId } : {})
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function formatDates(dates) {
|
|
280
|
+
return dates.length > 3 ? `${dates.slice(0, 3).join(', ')} +${dates.length - 3} more` : dates.join(', ');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function hasValue(value) {
|
|
284
|
+
return value !== undefined && value !== null && String(value).trim() !== '';
|
|
285
|
+
}
|