logwork-cli 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +53 -25
  2. package/RELEASE.md +4 -2
  3. package/bin/logwork.mjs +2 -2
  4. package/commands/commits.mjs +128 -13
  5. package/commands/history.mjs +0 -4
  6. package/commands/mcp.mjs +266 -0
  7. package/commands/quick.mjs +38 -23
  8. package/commands/reminder.mjs +41 -2
  9. package/commands/setup.mjs +197 -42
  10. package/commands/tui.mjs +1 -0
  11. package/docs/advanced.md +18 -7
  12. package/docs/mcp-setup.md +120 -26
  13. package/docs/operations-guide.md +50 -2
  14. package/docs/recipes.md +40 -0
  15. package/docs/security.md +23 -2
  16. package/examples/mcp/antigravity.mcp_config.json +2 -2
  17. package/examples/mcp/codex.config.toml +2 -2
  18. package/examples/mcp/copilot.mcp.json +2 -2
  19. package/lib/api-auth-forms.mjs +393 -0
  20. package/lib/api-auth-http.mjs +184 -0
  21. package/lib/api-auth-support.mjs +101 -0
  22. package/lib/api-auth-tokens.mjs +129 -0
  23. package/lib/api-auth.mjs +39 -813
  24. package/lib/api.mjs +38 -1422
  25. package/lib/apply-ledger.mjs +1 -1
  26. package/lib/batch-workflow.mjs +187 -2
  27. package/lib/commit-workflow.mjs +345 -23
  28. package/lib/config.mjs +7 -0
  29. package/lib/diagnostics.mjs +116 -4
  30. package/lib/jira-api.mjs +25 -3
  31. package/lib/jira-workflow.mjs +36 -6
  32. package/lib/launcher.mjs +303 -0
  33. package/lib/logwork-config.mjs +64 -13
  34. package/lib/manual-apply-state.mjs +15 -0
  35. package/lib/manual-commands.mjs +2 -2
  36. package/lib/manual-ink-app.mjs +142 -1282
  37. package/lib/manual-ink-draft-actions.mjs +227 -0
  38. package/lib/manual-ink-interaction.mjs +318 -0
  39. package/lib/manual-ink-session.mjs +41 -0
  40. package/lib/manual-ink-task-editor.mjs +338 -0
  41. package/lib/manual-ink-ui.mjs +38 -0
  42. package/lib/manual-ink-wizard.mjs +520 -0
  43. package/lib/manual-mcp.mjs +78 -20
  44. package/lib/manual-repl.mjs +171 -22
  45. package/lib/mcp-install.mjs +930 -0
  46. package/lib/mcp-server.mjs +51 -20
  47. package/lib/node-runtime.mjs +155 -0
  48. package/lib/paths.mjs +7 -0
  49. package/lib/project-mapping-workflow.mjs +32 -27
  50. package/lib/query-workflow.mjs +19 -1
  51. package/lib/reconciliation-workflow.mjs +1 -1
  52. package/lib/reminder-config.mjs +38 -2
  53. package/lib/reminder-scheduler.mjs +85 -18
  54. package/lib/reminder-service.mjs +42 -3
  55. package/lib/reminder-workflow.mjs +118 -22
  56. package/lib/repo-project-suggestions.mjs +285 -0
  57. package/lib/ro-api-diagnostics.mjs +109 -0
  58. package/lib/ro-api-entries.mjs +359 -0
  59. package/lib/ro-api-fields.mjs +290 -0
  60. package/lib/ro-api-http.mjs +68 -0
  61. package/lib/ro-api-projects.mjs +137 -0
  62. package/lib/ro-api-tasks.mjs +50 -0
  63. package/lib/ro-api-timesheet.mjs +413 -0
  64. package/lib/ro-delete-workflow.mjs +2 -1
  65. package/lib/ro-entry-reader.mjs +11 -6
  66. package/lib/ro-resubmit-workflow.mjs +2 -1
  67. package/lib/terminal-auth-prompts.mjs +7 -4
  68. package/lib/ui.mjs +121 -56
  69. package/package.json +11 -4
  70. package/skills/logwork/SKILL.md +10 -4
@@ -1,12 +1,13 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { z } from 'zod';
4
- import { ApiError } from './api.mjs';
4
+ import { ApiError } from './ro-api-http.mjs';
5
5
  import { queryApplyLedger } from './apply-ledger.mjs';
6
6
  import { readLogworkSkill } from './agent-skill.mjs';
7
7
  import { draftLogworkFromCommits } from './commit-workflow.mjs';
8
8
  import {
9
9
  applyLogworkBatch,
10
+ formatDuplicateBlock,
10
11
  formatToolResponse,
11
12
  listWorklogTasks,
12
13
  previewLogworkBatch
@@ -64,6 +65,7 @@ export const SERVER_INSTRUCTIONS = `logwork logs work hours to Resource Optimise
64
65
 
65
66
  Safety rules:
66
67
  - Writes are two calls to the same tool. Call it without confirm to get a preview (batchId or previewId plus a summary), show the summary to the user, wait for explicit approval, then call the same tool again with only that id and confirm: true. Never set confirm: true without approval.
68
+ - log_work previews flag possibleDuplicate entries (same date, worklog task, hours and description as an existing RO entry, or a repeated line). Show the warning; apply refuses them unless the user confirms it is new work and you call again with the same batchId and allowDuplicates: true. Never pass allowDuplicates or allowUnbooked without asking.
67
69
  - Never ask for or pass passwords, OTP codes, or tokens. If a tool returns authRequired, call login (target "ro" or "jira") and ask the user to finish login in their terminal.
68
70
 
69
71
  Logwork text format (log_work and log_jira_work):
@@ -75,7 +77,7 @@ Logwork text format (log_work and log_jira_work):
75
77
  - Lines without "|" are accepted only when the text is itself a catalog task name (no description is written), or when defaultWorklogTask is passed (the text then becomes the description).
76
78
  - Jira needs exactly one issue key such as SCB-213 in the line.
77
79
 
78
- Recommended RO flow: get_logwork (what is booked/logged) -> draft_from_commits (the user's git commits as draft lines) -> list_tasks(projectId) -> log_work(text) -> user approval -> log_work(batchId, confirm: true).
80
+ Recommended RO flow: get_logwork (what is booked/logged) -> draft_from_commits (the user's git commits as draft lines plus projectOverrides) -> list_tasks(projectId) -> log_work(text, projectOverrides) -> user approval -> log_work(batchId, confirm: true).
79
81
  Base every line on the user's commits or on what the user tells you; never invent work. The log_from_commits prompt has the full procedure.
80
82
  Fixing existing RO entries (logworkId from get_logwork): submitted/approved entries -> edit_entry (hours, worklog task, type of work, description; apply replaces the entry with a new id, approved immediately); rejected entries -> resubmit_entry; removing an entry -> delete_entry.
81
83
  If a preview entry is unresolved: worklog_task_required / task_not_found means fix the worklog task (see availableTaskNames); project reasons mean ask the user to pick a project (projectOverrides) or save a mapping with map_project.`;
@@ -133,7 +135,7 @@ server.registerTool('list_tasks', {
133
135
 
134
136
  server.registerTool('draft_from_commits', {
135
137
  title: 'Draft logwork from git commits',
136
- 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 split each day\'s remaining RO hours (booked - logged) across the groups. Returns draftText in the log_work line format. Review it with the user, check task names with list_tasks, then pass it to log_work.',
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.',
137
139
  annotations: READ_ONLY,
138
140
  inputSchema: {
139
141
  period: z.enum(PERIOD_VALUES).optional().describe('Preset period. Defaults to today.'),
@@ -147,27 +149,29 @@ server.registerTool('draft_from_commits', {
147
149
 
148
150
  server.registerTool('log_work', {
149
151
  title: 'Log work to Resource Optimiser',
150
- description: `Resource Optimiser only: create new RO entries (worklog task + hours + date + type of work + description) from a weekly logwork block. ${TWO_STEP} The preview resolves the project (bookings, mappings, ticket prefixes) and worklog task per line; unresolved lines block apply. Use log_jira_work for Jira.`,
152
+ description: `Resource Optimiser only: create new RO entries (worklog task + hours + date + type of work + description) from a weekly logwork block. ${TWO_STEP} The preview resolves the project (bookings, mappings, ticket prefixes) and worklog task per line; unresolved lines block apply. It also reads each date's RO entries and flags possibleDuplicate lines (same date, worklog task, hours and description, or a repeated line); apply refuses them unless allowDuplicates: true. Use log_jira_work for Jira.`,
151
153
  annotations: WRITE,
152
154
  inputSchema: {
153
155
  text: z.string().min(1).optional().describe(ENTRY_TEXT_DESCRIPTION),
154
- projectOverrides: z.record(z.string(), z.union([z.string(), z.number()])).optional().describe('Preview call only. Map preview entry id (e.g. "2026-06-01-01") to the projectMemberId the user chose for an unresolved entry.'),
156
+ projectOverrides: z.record(z.string(), z.union([z.string(), z.number()])).optional().describe('Preview call only. Map preview entry id (e.g. "2026-06-01-01") to the projectMemberId the user chose for an unresolved entry, or pass the projectOverrides returned by draft_from_commits with its draftText unchanged.'),
155
157
  defaultWorklogTask: z.string().min(1).optional().describe('Preview call only. Worklog task name (or "#<id>") for lines without "|" whose text is not a catalog task; that text becomes the description. Example: "Feature Development".'),
156
158
  typeOfWork: TYPE_OF_WORK_SCHEMA.optional().describe('Preview call only. Override type_of_work for every entry. When omitted, each entry takes its worklog task type, else "other".'),
157
159
  batchId: BATCH_ID_SCHEMA,
158
160
  allowUnbooked: z.boolean().optional().describe('Apply call only. Allow entries resolved to a project membership without a booking on that date.'),
161
+ allowDuplicates: z.boolean().optional().describe('Apply call only. Allow entries the preview flagged as possibleDuplicate (same date, worklog task, hours and description as an existing RO entry or an earlier line). Pass true only after the user confirmed they are new work.'),
159
162
  confirm: CONFIRM_SCHEMA
160
163
  }
161
- }, withAuthRequiredHandling(async ({ text, projectOverrides, defaultWorklogTask, typeOfWork, batchId, allowUnbooked = false, confirm }) => {
164
+ }, withAuthRequiredHandling(async ({ text, projectOverrides, defaultWorklogTask, typeOfWork, batchId, allowUnbooked = false, allowDuplicates = false, confirm }) => {
162
165
  prunePreviewCache(previews);
163
166
  if (confirm === true) {
164
167
  assertApplyOnly('log_work', { text, projectOverrides, defaultWorklogTask, typeOfWork });
165
- // Check allowUnbooked before the preview is consumed, so the agent can
166
- // retry the same batchId with allowUnbooked: true.
168
+ // Check allowUnbooked and allowDuplicates before the preview is consumed,
169
+ // so the agent can retry the same batchId with the flag set.
167
170
  const pending = resolveApprovedBatch({ cache: previews, batchId });
168
171
  assertUnbookedAllowed(pending, allowUnbooked);
172
+ assertDuplicatesAllowed(pending, allowDuplicates);
169
173
  const approvedBatch = consumeApprovedBatch({ cache: previews, batchId });
170
- return formatToolResponse(await applyLogworkBatch({ batch: approvedBatch, confirm, allowUnbooked }));
174
+ return formatToolResponse(await applyLogworkBatch({ batch: approvedBatch, confirm, allowUnbooked, allowDuplicates }));
171
175
  }
172
176
  requirePreviewInput('log_work', { text });
173
177
  const preview = await previewLogworkBatch({ text, projectOverrides: projectOverrides || {}, typeOfWork, defaultWorklogTask });
@@ -261,8 +265,7 @@ server.registerTool('log_jira_work', {
261
265
  prunePreviewCache(jiraPreviews);
262
266
  if (confirm === true) {
263
267
  assertApplyOnly('log_jira_work', { text });
264
- const approvedBatch = consumeApprovedJiraBatch({ cache: jiraPreviews, batchId });
265
- return formatToolResponse(await applyJiraWorklogBatch({ batch: approvedBatch, confirm }));
268
+ return applyCachedJiraWork({ cache: jiraPreviews, batchId, confirm });
266
269
  }
267
270
  requirePreviewInput('log_jira_work', { text });
268
271
  const preview = await previewJiraWorklogBatch({ text });
@@ -308,18 +311,19 @@ server.registerTool('get_history', {
308
311
 
309
312
  server.registerTool('map_project', {
310
313
  title: 'Save a project mapping',
311
- description: 'Save a local ticket-prefix/keyword -> RO project mapping so future log_work previews resolve the project automatically. Writes .logwork.json; requires confirm: true after the user approved the mapping.',
314
+ description: 'Save a local ticket-prefix/keyword/git repo -> RO project mapping so future log_work previews resolve the project automatically and draft_from_commits allocates a repo\'s commits to its project. Pass tickets, repos, or both. Writes .logwork.json; requires confirm: true after the user approved the mapping.',
312
315
  annotations: { ...LOCAL_WRITE, idempotentHint: true },
313
316
  inputSchema: {
314
317
  projectMemberId: z.union([z.string(), z.number()]).optional().describe('RO projectMemberId from list_projects or preview setupSuggestions.'),
315
318
  projectName: z.string().optional().describe('Project name to match against the user\'s memberships when projectMemberId is not given.'),
316
- tickets: z.array(z.string()).min(1).describe('Ticket prefixes to map, for example ["SCB"].'),
319
+ tickets: z.array(z.string()).optional().describe('Ticket prefixes to map, for example ["SCB"]. At least one ticket prefix or repo is required.'),
317
320
  keywords: z.array(z.string()).optional().describe('Optional task keywords to map to the project.'),
321
+ repos: z.array(z.string().min(1)).max(20).optional().describe('Absolute paths of git repos whose commits belong to this project (a subfolder is stored as its repo root). draft_from_commits uses them to pick the project per commit.'),
318
322
  scope: z.enum(['user', 'project']).optional().describe('user (default): ~/.logwork-cli/.logwork.json for this machine. project: .logwork.json in the current repo.'),
319
323
  confirm: z.boolean().describe('Must be true, and only after explicit user approval.')
320
324
  }
321
- }, withAuthRequiredHandling(async ({ projectMemberId, projectName, tickets, keywords = [], scope = 'user', confirm }) => {
322
- const result = await upsertProjectMapping({ projectMemberId, projectName, tickets, keywords, scope, confirm });
325
+ }, withAuthRequiredHandling(async ({ projectMemberId, projectName, tickets = [], keywords = [], repos = [], scope = 'user', confirm }) => {
326
+ const result = await upsertProjectMapping({ projectMemberId, projectName, tickets, keywords, repos, scope, confirm });
323
327
  return formatToolResponse(result);
324
328
  }));
325
329
 
@@ -344,15 +348,19 @@ server.registerTool('login', {
344
348
 
345
349
  server.registerTool('reminder', {
346
350
  title: 'Logwork reminder',
347
- 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. 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.',
348
352
  annotations: LOCAL_WRITE,
349
353
  inputSchema: {
350
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.'),
351
355
  time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).optional().describe('enable only. Local time in 24-hour HH:mm. Defaults to 17:00.'),
352
356
  target: z.enum(['ro', 'jira', 'both']).optional().describe('enable/test only. What to check. Defaults to both (enable) or the configured target (test).'),
357
+ repos: z.array(z.string().min(1)).max(20).optional().describe('enable only. Absolute paths of git repos whose commits today the reminder counts when RO hours are missing. Replaces the saved list; [] clears it; omit to keep it.'),
353
358
  confirm: z.boolean().optional().describe('Required (true) for enable, disable and test, after explicit user approval.')
354
359
  }
355
- }, async ({ action, time, target, confirm }) => {
360
+ }, async ({ action, time, target, repos, confirm }) => {
361
+ if (action !== 'enable' && repos !== undefined) {
362
+ throw new Error('repos is only valid when enabling the reminder.');
363
+ }
356
364
  if (action === 'status') {
357
365
  return formatToolResponse(await getLogworkReminderStatus());
358
366
  }
@@ -366,7 +374,7 @@ server.registerTool('reminder', {
366
374
  throw new Error('target is not valid when disabling the reminder.');
367
375
  }
368
376
  if (action === 'enable') {
369
- return formatToolResponse(await enableLogworkReminder({ time, target, confirm }));
377
+ return formatToolResponse(await enableLogworkReminder({ time, target, repos, confirm }));
370
378
  }
371
379
  if (action === 'disable') {
372
380
  return formatToolResponse(await disableLogworkReminder({ confirm }));
@@ -550,7 +558,14 @@ export function resolveApprovedBatch({
550
558
  return clonePreview(cached.preview);
551
559
  }
552
560
 
553
- function assertUnbookedAllowed(batch, allowUnbooked) {
561
+ export function assertDuplicatesAllowed(batch, allowDuplicates) {
562
+ const duplicates = (batch?.entries || []).filter((entry) => entry.possibleDuplicate);
563
+ if (duplicates.length && allowDuplicates !== true) {
564
+ throw new Error(`Nothing was written. ${formatDuplicateBlock(duplicates)} Ask the user whether this is new work, then call log_work again with the same batchId, confirm: true and allowDuplicates: true.`);
565
+ }
566
+ }
567
+
568
+ export function assertUnbookedAllowed(batch, allowUnbooked) {
554
569
  const unbooked = (batch?.entries || []).filter((entry) => entry.requiresAllowUnbooked);
555
570
  if (unbooked.length && allowUnbooked !== true) {
556
571
  throw new Error(`Nothing was written. ${unbooked.length} entr${unbooked.length === 1 ? 'y has' : 'ies have'} no booking on that date (${unbooked.map((entry) => entry.id).join(', ')}). Ask the user, then call log_work again with the same batchId, confirm: true and allowUnbooked: true.`);
@@ -573,6 +588,19 @@ export function consumeApprovedBatch({
573
588
  return approvedBatch;
574
589
  }
575
590
 
591
+ // The log_jira_work apply call. With LOGWORK_DRY_RUN=1 the apply sends no
592
+ // worklog and the response summary starts with "DRY RUN".
593
+ export async function applyCachedJiraWork({
594
+ cache,
595
+ batchId,
596
+ confirm,
597
+ applyOptions = {},
598
+ now = Date.now()
599
+ }) {
600
+ const approvedBatch = consumeApprovedJiraBatch({ cache, batchId, now });
601
+ return formatToolResponse(await applyJiraWorklogBatch({ ...applyOptions, batch: approvedBatch, confirm }));
602
+ }
603
+
576
604
  export function resolveApprovedJiraBatch({
577
605
  cache,
578
606
  batchId,
@@ -684,7 +712,10 @@ export function createPreviewFingerprint(preview = {}) {
684
712
  confidence: numberOrNull(entry?.confidence),
685
713
  matchedProject: projectFingerprint(entry?.matchedProject),
686
714
  booked: booleanOrNull(entry?.booked),
687
- requiresAllowUnbooked: booleanOrNull(entry?.requiresAllowUnbooked)
715
+ requiresAllowUnbooked: booleanOrNull(entry?.requiresAllowUnbooked),
716
+ possibleDuplicate: entry?.possibleDuplicate
717
+ ? textOrNull(entry.possibleDuplicate.logworkId ?? entry.possibleDuplicate.entryId)
718
+ : null
688
719
  }))
689
720
  });
690
721
  }
@@ -0,0 +1,155 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { basename } from 'node:path';
3
+ import { atomicWriteFile } from './atomic-file.mjs';
4
+ import { LAUNCHER_FILE_NAME, resolveLaunchCommand } from './launcher.mjs';
5
+ import { runtimeInfoPath } from './paths.mjs';
6
+
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.
12
+
13
+ export async function writeRuntimeInfo({
14
+ path = runtimeInfoPath(),
15
+ nodePath = process.execPath,
16
+ nodeVersion = process.version,
17
+ version = null,
18
+ now = () => new Date(),
19
+ writeFile = atomicWriteFile
20
+ } = {}) {
21
+ const info = {
22
+ nodePath: String(nodePath || ''),
23
+ nodeVersion: String(nodeVersion || ''),
24
+ installedAt: now().toISOString(),
25
+ version: version ? String(version) : null
26
+ };
27
+ await writeFile(path, `${JSON.stringify(info, null, 2)}\n`);
28
+ return info;
29
+ }
30
+
31
+ export async function loadRuntimeInfo({
32
+ path = runtimeInfoPath(),
33
+ readFile = fs.readFile
34
+ } = {}) {
35
+ let parsed;
36
+ try {
37
+ parsed = JSON.parse(await readFile(path, 'utf8'));
38
+ } catch (error) {
39
+ if (error.code === 'ENOENT' || error instanceof SyntaxError) {
40
+ return null;
41
+ }
42
+ throw error;
43
+ }
44
+ if (!parsed || typeof parsed !== 'object' || typeof parsed.nodePath !== 'string' || !parsed.nodePath) {
45
+ return null;
46
+ }
47
+ return {
48
+ nodePath: parsed.nodePath,
49
+ nodeVersion: typeof parsed.nodeVersion === 'string' ? parsed.nodeVersion : '',
50
+ installedAt: typeof parsed.installedAt === 'string' ? parsed.installedAt : '',
51
+ version: typeof parsed.version === 'string' ? parsed.version : null
52
+ };
53
+ }
54
+
55
+ // ok: the recorded node is the current one (same file after symlinks).
56
+ // missing: the recorded node no longer exists.
57
+ // stale: it exists but differs from the current, working node.
58
+ export async function inspectNodePath(recordedPath, {
59
+ currentPath = process.execPath,
60
+ platform = process.platform,
61
+ exists = pathExists,
62
+ realpath = fs.realpath
63
+ } = {}) {
64
+ const recorded = String(recordedPath || '');
65
+ if (!recorded) {
66
+ return { status: 'unknown', recordedPath: '', currentPath };
67
+ }
68
+ if (!await exists(recorded)) {
69
+ return { status: 'missing', recordedPath: recorded, currentPath };
70
+ }
71
+ const [left, right] = await Promise.all([
72
+ realpath(recorded).catch(() => recorded),
73
+ realpath(currentPath).catch(() => currentPath)
74
+ ]);
75
+ const same = platform === 'win32'
76
+ ? String(left).toLowerCase() === String(right).toLowerCase()
77
+ : left === right;
78
+ if (same) {
79
+ return { status: 'ok', recordedPath: recorded, currentPath };
80
+ }
81
+ if (!await exists(currentPath)) {
82
+ // Not stale: there is no working node to switch to.
83
+ return { status: 'differs', recordedPath: recorded, currentPath };
84
+ }
85
+ return { status: 'stale', recordedPath: recorded, currentPath };
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.
124
+ export function formatNodePathIssue(source, inspection) {
125
+ const { status, recordedPath, currentPath, kind } = inspection || {};
126
+ if (source === 'reminder') {
127
+ const what = kind === 'launcher' ? 'the logwork launcher at' : 'Node at';
128
+ if (status === 'missing') {
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\`).`;
133
+ }
134
+ if (status === 'stale') {
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.`;
136
+ }
137
+ return '';
138
+ }
139
+ if (status === 'missing') {
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.`;
141
+ }
142
+ if (status === 'stale') {
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.`;
144
+ }
145
+ return '';
146
+ }
147
+
148
+ async function pathExists(path) {
149
+ try {
150
+ await fs.access(path);
151
+ return true;
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
package/lib/paths.mjs CHANGED
@@ -9,6 +9,7 @@ export const APPLY_LEDGER_FILE_NAME = 'apply-ledger.json';
9
9
  export const UPDATE_STATE_FILE_NAME = 'update-state.json';
10
10
  export const REMINDER_CONFIG_FILE_NAME = 'reminder.json';
11
11
  export const REMINDER_STATE_FILE_NAME = 'reminder-state.json';
12
+ export const RUNTIME_INFO_FILE_NAME = 'runtime.json';
12
13
 
13
14
  // Names used before the package was renamed from logwork-helper to logwork-cli.
14
15
  // lib/legacy-migration.mjs copies state from these locations on first run.
@@ -51,6 +52,12 @@ export function reminderStatePath() {
51
52
  return resolve(helperHome(), REMINDER_STATE_FILE_NAME);
52
53
  }
53
54
 
55
+ // Written by `logwork setup`: the node binary and version the installed
56
+ // runtime, MCP configs and reminder were set up with.
57
+ export function runtimeInfoPath() {
58
+ return resolve(helperHome(), RUNTIME_INFO_FILE_NAME);
59
+ }
60
+
54
61
  export function legacyUserConfigPath() {
55
62
  // A custom home (tests, separate profiles) keeps its state isolated from
56
63
  // the real ~/.logwork-helper.json.
@@ -1,10 +1,12 @@
1
- import { ApiError, getProjects } from './api.mjs';
1
+ import { homedir } from 'node:os';
2
+ import { resolve } from 'node:path';
3
+ import { resolveGitRepoRoot } from './commit-workflow.mjs';
2
4
  import {
3
5
  configPath,
4
6
  loadLocalConfig,
5
7
  upsertProjectMappingConfig
6
8
  } from './logwork-config.mjs';
7
- import { createResourceOptimiserSession } from './query-workflow.mjs';
9
+ import { createMembershipProjectsFetcher } from './query-workflow.mjs';
8
10
 
9
11
  export async function listLogworkProjects({
10
12
  cwd = process.cwd(),
@@ -34,17 +36,35 @@ export async function upsertProjectMapping({
34
36
  projectName,
35
37
  tickets = [],
36
38
  keywords = [],
39
+ repos = [],
37
40
  scope = 'user',
38
41
  confirm,
39
42
  cwd = process.cwd(),
40
- fetchProjects
43
+ fetchProjects,
44
+ resolveRepoRoot = resolveGitRepoRoot
41
45
  } = {}) {
42
46
  if (confirm !== true) {
43
47
  throw new Error('map_project requires confirm: true.');
44
48
  }
45
49
 
46
- if (!Array.isArray(tickets) || !tickets.length) {
47
- throw new Error('map_project requires at least one ticket prefix.');
50
+ const ticketList = Array.isArray(tickets) ? tickets : [];
51
+ const repoList = Array.isArray(repos) ? repos.filter((repo) => typeof repo === 'string' && repo.trim()) : [];
52
+ if (!ticketList.length && !repoList.length) {
53
+ throw new Error('map_project requires at least one ticket prefix or repo.');
54
+ }
55
+
56
+ // Commit drafts match a commit's git repo root, so a subfolder is stored as
57
+ // the root of the repo that contains it.
58
+ const repoRoots = [];
59
+ for (const repo of repoList) {
60
+ const trimmed = repo.trim();
61
+ const expanded = trimmed === '~' || /^~[\\/]/.test(trimmed) ? `${homedir()}${trimmed.slice(1)}` : trimmed;
62
+ const path = resolve(cwd, expanded);
63
+ try {
64
+ repoRoots.push(await resolveRepoRoot(path));
65
+ } catch (error) {
66
+ throw new Error(`${path} is not a git repository: ${error.message}`);
67
+ }
48
68
  }
49
69
 
50
70
  const projects = sanitizeProjects(await (fetchProjects || createMembershipProjectsFetcher())());
@@ -60,8 +80,9 @@ export async function upsertProjectMapping({
60
80
  cwd,
61
81
  scope,
62
82
  project: resolution.project,
63
- tickets,
64
- keywords
83
+ tickets: ticketList,
84
+ keywords,
85
+ repos: repoRoots
65
86
  });
66
87
 
67
88
  return {
@@ -72,8 +93,9 @@ export async function upsertProjectMapping({
72
93
  projects,
73
94
  summary: [
74
95
  `${result.created ? 'Created' : 'Updated'} project mapping for ${result.mapping.projectName}.`,
75
- `Tickets: ${result.mapping.tickets.join(', ')}.`,
76
- result.mapping.keywords.length ? `Keywords: ${result.mapping.keywords.join(', ')}.` : 'Keywords: none.'
96
+ result.mapping.tickets.length ? `Tickets: ${result.mapping.tickets.join(', ')}.` : 'Tickets: none.',
97
+ result.mapping.keywords.length ? `Keywords: ${result.mapping.keywords.join(', ')}.` : 'Keywords: none.',
98
+ result.mapping.repos?.length ? `Repos: ${result.mapping.repos.join(', ')}.` : 'Repos: none.'
77
99
  ].join('\n')
78
100
  };
79
101
  }
@@ -144,24 +166,7 @@ export function enrichRoPreviewWithJiraProjects(roPreview, jiraPreview) {
144
166
  };
145
167
  }
146
168
 
147
- function createMembershipProjectsFetcher() {
148
- const session = createResourceOptimiserSession();
149
- return async () => {
150
- const { token, userId } = await session.get();
151
- try {
152
- return await getProjects(token, userId);
153
- } catch (error) {
154
- if (!(error instanceof ApiError) || error.status !== 401) {
155
- throw error;
156
- }
157
-
158
- const refreshed = await session.get({ forceLogin: true });
159
- return getProjects(refreshed.token, refreshed.userId);
160
- }
161
- };
162
- }
163
-
164
- function resolveProjectSelection(projects, { projectMemberId, projectName }) {
169
+ export function resolveProjectSelection(projects, { projectMemberId, projectName }) {
165
170
  if (projectMemberId !== undefined && projectMemberId !== null && String(projectMemberId).trim() !== '') {
166
171
  const project = projects.find((candidate) => String(candidate.projectMemberId) === String(projectMemberId));
167
172
  return project
@@ -1,10 +1,10 @@
1
- import { CONFIG } from './config.mjs';
2
1
  import { getFreshToken, getUserIdFromJwt } from './auth.mjs';
3
2
  import {
4
3
  ApiError,
5
4
  combineNormalizationDiagnostics,
6
5
  getDayLogs,
7
6
  getLogworkDay,
7
+ getProjects,
8
8
  getLogworkRejected,
9
9
  getNormalizationDiagnostics,
10
10
  getTimesheetRangeResult
@@ -289,6 +289,24 @@ export function createDayLogsFetcher() {
289
289
  };
290
290
  }
291
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
+
292
310
  export function createResourceOptimiserSession() {
293
311
  let cached = null;
294
312
 
@@ -1,4 +1,4 @@
1
- import { ApiError } from './api.mjs';
1
+ import { ApiError } from './ro-api-http.mjs';
2
2
  import {
3
3
  getJiraIssueWorklogs,
4
4
  searchJiraIssues
@@ -1,4 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute, resolve } from 'node:path';
2
4
  import { atomicWriteFile } from './atomic-file.mjs';
3
5
  import { reminderConfigPath } from './paths.mjs';
4
6
 
@@ -6,13 +8,15 @@ export const REMINDER_TARGETS = Object.freeze(['ro', 'jira', 'both']);
6
8
  export const WEEKDAY_NUMBERS = Object.freeze([1, 2, 3, 4, 5]);
7
9
  export const DEFAULT_REMINDER_TIME = '17:00';
8
10
  export const DEFAULT_REMINDER_TARGET = 'both';
11
+ export const MAX_REMINDER_REPOS = 20;
9
12
 
10
13
  export function defaultReminderConfig() {
11
14
  return {
12
15
  enabled: false,
13
16
  time: DEFAULT_REMINDER_TIME,
14
17
  target: DEFAULT_REMINDER_TARGET,
15
- days: [...WEEKDAY_NUMBERS]
18
+ days: [...WEEKDAY_NUMBERS],
19
+ repos: []
16
20
  };
17
21
  }
18
22
 
@@ -46,10 +50,42 @@ export function normalizeReminderConfig(config = {}) {
46
50
  enabled: Boolean(config.enabled),
47
51
  time,
48
52
  target,
49
- days
53
+ days,
54
+ repos: normalizeReminderRepos(config.repos ?? defaults.repos)
50
55
  };
51
56
  }
52
57
 
58
+ // Git repos whose commits the reminder summarizes. Stored as absolute paths
59
+ // because the scheduled run starts in the helper home, not where the user
60
+ // typed the command. `~` is expanded; relative paths resolve against cwd.
61
+ export function normalizeReminderRepos(value, { cwd = process.cwd(), home = homedir() } = {}) {
62
+ if (value === undefined || value === null) {
63
+ return [];
64
+ }
65
+ if (!Array.isArray(value)) {
66
+ throw new Error('Reminder repos must be a list of paths.');
67
+ }
68
+ const repos = [];
69
+ for (const item of value) {
70
+ const raw = String(item ?? '').trim();
71
+ if (!raw) {
72
+ continue;
73
+ }
74
+ if (/[\0\r\n]/.test(raw)) {
75
+ throw new Error('Reminder repo paths cannot contain control characters.');
76
+ }
77
+ const expanded = raw === '~' || /^~[\\/]/.test(raw) ? `${home}${raw.slice(1)}` : raw;
78
+ const absolute = isAbsolute(expanded) ? resolve(expanded) : resolve(cwd, expanded);
79
+ if (!repos.includes(absolute)) {
80
+ repos.push(absolute);
81
+ }
82
+ }
83
+ if (repos.length > MAX_REMINDER_REPOS) {
84
+ throw new Error(`Reminder repos are limited to ${MAX_REMINDER_REPOS} paths.`);
85
+ }
86
+ return repos;
87
+ }
88
+
53
89
  export function normalizeReminderTime(value) {
54
90
  const time = String(value || '').trim();
55
91
  const match = /^(\d{2}):(\d{2})$/.exec(time);