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.
@@ -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: offer map_project with repos.',
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 Node path in the schedule no longer exists. No credentials are stored in the schedule.',
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.'),
@@ -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
- // The node binary that runs the MCP server and the reminder is stored as an
6
- // absolute path (IDE configs, the launchd plist, the Windows task). With nvm,
7
- // Homebrew or a Node installer upgrade that path can disappear or stop being
8
- // the node the user runs. `logwork setup` records it here so `logwork doctor`
9
- // and `logwork reminder status` can tell when it went stale.
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
- // source "runtime": the node recorded by `logwork setup` (used by the printed
87
- // MCP configs). source "reminder": the node in the installed OS schedule.
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 Node at ${recordedPath}, which no longer exists, so the reminder cannot run. Fix: run \`logwork reminder enable\` (or \`logwork setup\`) to re-register it with the current Node.`;
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 Node at ${recordedPath}, but the current Node is ${currentPath}. Fix: run \`logwork reminder enable\` to re-register it with the current Node.`;
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\` and paste the new MCP config "command" into your IDE.`;
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\` and update the MCP config "command" in your IDE.`;
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 { createResourceOptimiserSession } from './query-workflow.mjs';
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 createMembershipProjectsFetcher() {
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
@@ -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 `node <home>/bin/logwork.mjs reminder run --home <home>`.
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
- nodePath,
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
- nodePath,
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 node binary the installed schedule runs: the first ProgramArguments
155
- // entry of the launchd plist, or the <Command> of the Windows task. Returns
156
- // null when no schedule is installed or it cannot be read. Read-only.
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
- export function buildMacosReminderPlist({ config, nodePath, runnerPath, home }) {
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
- <string>${escapeXml(assertSafePath(nodePath))}</string>
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 = [nodePath, runnerPath, 'reminder', 'run', '--home', home]
246
+ const action = scheduleProgram({ launch, nodePath, runnerPath, home })
231
247
  .map(quoteWindowsActionPart)
232
248
  .join(' ');
233
249
  const dayNames = normalized.days.map((day) => ({
@@ -1,4 +1,5 @@
1
- import { formatNodePathIssue, inspectNodePath } from './node-runtime.mjs';
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 = inspectNodePath
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 node path; nvm or Homebrew can remove it
104
- // while the schedule still looks installed.
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
+ }