logwork-cli 1.1.1 → 1.2.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 (64) hide show
  1. package/README.md +23 -7
  2. package/RELEASE.md +1 -0
  3. package/commands/commits.mjs +57 -6
  4. package/commands/history.mjs +0 -4
  5. package/commands/quick.mjs +38 -23
  6. package/commands/reminder.mjs +41 -2
  7. package/commands/setup.mjs +50 -16
  8. package/commands/tui.mjs +1 -0
  9. package/docs/advanced.md +12 -2
  10. package/docs/mcp-setup.md +56 -14
  11. package/docs/operations-guide.md +35 -2
  12. package/docs/recipes.md +27 -0
  13. package/docs/security.md +3 -1
  14. package/examples/mcp/antigravity.mcp_config.json +1 -1
  15. package/examples/mcp/codex.config.toml +1 -1
  16. package/examples/mcp/copilot.mcp.json +1 -1
  17. package/lib/api-auth-forms.mjs +393 -0
  18. package/lib/api-auth-http.mjs +184 -0
  19. package/lib/api-auth-support.mjs +101 -0
  20. package/lib/api-auth-tokens.mjs +129 -0
  21. package/lib/api-auth.mjs +39 -813
  22. package/lib/api.mjs +38 -1422
  23. package/lib/apply-ledger.mjs +1 -1
  24. package/lib/batch-workflow.mjs +187 -2
  25. package/lib/commit-workflow.mjs +188 -21
  26. package/lib/config.mjs +7 -0
  27. package/lib/diagnostics.mjs +53 -4
  28. package/lib/jira-api.mjs +25 -3
  29. package/lib/jira-workflow.mjs +36 -6
  30. package/lib/logwork-config.mjs +64 -13
  31. package/lib/manual-apply-state.mjs +15 -0
  32. package/lib/manual-commands.mjs +2 -2
  33. package/lib/manual-ink-app.mjs +108 -1281
  34. package/lib/manual-ink-draft-actions.mjs +227 -0
  35. package/lib/manual-ink-interaction.mjs +306 -0
  36. package/lib/manual-ink-session.mjs +41 -0
  37. package/lib/manual-ink-task-editor.mjs +338 -0
  38. package/lib/manual-ink-wizard.mjs +520 -0
  39. package/lib/manual-mcp.mjs +27 -15
  40. package/lib/manual-repl.mjs +39 -3
  41. package/lib/mcp-server.mjs +51 -20
  42. package/lib/node-runtime.mjs +115 -0
  43. package/lib/paths.mjs +7 -0
  44. package/lib/project-mapping-workflow.mjs +30 -7
  45. package/lib/query-workflow.mjs +0 -1
  46. package/lib/reconciliation-workflow.mjs +1 -1
  47. package/lib/reminder-config.mjs +38 -2
  48. package/lib/reminder-scheduler.mjs +54 -3
  49. package/lib/reminder-service.mjs +28 -3
  50. package/lib/reminder-workflow.mjs +118 -22
  51. package/lib/ro-api-diagnostics.mjs +109 -0
  52. package/lib/ro-api-entries.mjs +359 -0
  53. package/lib/ro-api-fields.mjs +290 -0
  54. package/lib/ro-api-http.mjs +68 -0
  55. package/lib/ro-api-projects.mjs +137 -0
  56. package/lib/ro-api-tasks.mjs +50 -0
  57. package/lib/ro-api-timesheet.mjs +413 -0
  58. package/lib/ro-delete-workflow.mjs +2 -1
  59. package/lib/ro-entry-reader.mjs +11 -6
  60. package/lib/ro-resubmit-workflow.mjs +2 -1
  61. package/lib/terminal-auth-prompts.mjs +7 -4
  62. package/lib/ui.mjs +80 -59
  63. package/package.json +11 -4
  64. package/skills/logwork/SKILL.md +5 -4
@@ -1,4 +1,5 @@
1
1
  import { stdin as defaultInput, stdout as defaultOutput } from 'node:process';
2
+ import { resolve as resolvePath } from 'node:path';
2
3
  import { confirm, isCancel } from '@clack/prompts';
3
4
  import { getStoredAuthStatus, loginResourceOptimiser } from './auth.mjs';
4
5
  import { queryApplyLedger } from './apply-ledger.mjs';
@@ -30,8 +31,10 @@ import {
30
31
  import { findManualCommand, formatManualHelp } from './manual-commands.mjs';
31
32
  import {
32
33
  buildManualApplyConfirmation,
34
+ buildManualDuplicateConfirmation,
33
35
  buildManualUnbookedConfirmation,
34
36
  getManualApplyBlocker,
37
+ manualDuplicateEntryCount,
35
38
  manualUnbookedEntryCount
36
39
  } from './manual-apply-state.mjs';
37
40
  import { runManualInkApp } from './manual-ink-app.mjs';
@@ -363,7 +366,11 @@ export async function executeManualCommand(command, session, context) {
363
366
  }
364
367
 
365
368
  if (command.type === 'map') {
366
- const ok = await prompts.confirm(`Map ${command.tickets.join(', ')} to projectMemberId ${command.projectMemberId}?`);
369
+ const subject = [
370
+ ...command.tickets,
371
+ ...(command.repos || []).map((repo) => `repo ${resolvePath(cwd, repo)}`)
372
+ ].join(', ');
373
+ const ok = await prompts.confirm(`Map ${subject} to projectMemberId ${command.projectMemberId}?`);
367
374
  if (!ok) {
368
375
  print('Mapping cancelled.');
369
376
  return { exit: false };
@@ -373,6 +380,7 @@ export async function executeManualCommand(command, session, context) {
373
380
  projectMemberId: command.projectMemberId,
374
381
  tickets: command.tickets,
375
382
  keywords: [],
383
+ ...(command.repos?.length ? { repos: command.repos } : {}),
376
384
  scope: 'user',
377
385
  confirm: true,
378
386
  cwd
@@ -666,10 +674,20 @@ async function applyManualRoPreview({
666
674
  }
667
675
  }
668
676
 
677
+ let allowDuplicates = false;
678
+ if (manualDuplicateEntryCount(preview)) {
679
+ allowDuplicates = await prompts.confirm(buildManualDuplicateConfirmation(preview), false);
680
+ if (!allowDuplicates) {
681
+ print('Resource Optimiser apply cancelled because possible duplicates were not approved. Nothing was written.');
682
+ return false;
683
+ }
684
+ }
685
+
669
686
  const result = await runWithInlineAuth(() => workflows.applyLogworkBatch({
670
687
  batch: preview,
671
688
  confirm: true,
672
689
  allowUnbooked,
690
+ allowDuplicates,
673
691
  cwd,
674
692
  ledgerContext: { flowTarget }
675
693
  }), { workflows, print });
@@ -871,9 +889,27 @@ function parseTargetCommand(type, parts, {
871
889
  };
872
890
  }
873
891
 
892
+ const MAP_USAGE = 'Usage: /map <TICKET_PREFIX[,PREFIX...]> <projectMemberId> | /map --repo [path] <projectMemberId>';
893
+
874
894
  function parseMapCommand(parts) {
895
+ if (parts[0] === '--repo') {
896
+ // The repo path defaults to the current folder; a path with spaces may be
897
+ // quoted.
898
+ const projectMemberId = parts.length > 1 ? parts[parts.length - 1] : '';
899
+ const repo = parts.slice(1, -1).join(' ').replace(/^(["'])(.*)\1$/, '$2').trim() || '.';
900
+ if (!projectMemberId || projectMemberId.startsWith('-')) {
901
+ throw new Error(MAP_USAGE);
902
+ }
903
+ return {
904
+ type: 'map',
905
+ tickets: [],
906
+ repos: [repo],
907
+ projectMemberId
908
+ };
909
+ }
910
+
875
911
  if (parts.length < 2) {
876
- throw new Error('Usage: /map <TICKET_PREFIX[,PREFIX...]> <projectMemberId>');
912
+ throw new Error(MAP_USAGE);
877
913
  }
878
914
 
879
915
  const tickets = parts[0]
@@ -883,7 +919,7 @@ function parseMapCommand(parts) {
883
919
  const projectMemberId = parts[1];
884
920
 
885
921
  if (!tickets.length || !projectMemberId) {
886
- throw new Error('Usage: /map <TICKET_PREFIX[,PREFIX...]> <projectMemberId>');
922
+ throw new Error(MAP_USAGE);
887
923
  }
888
924
 
889
925
  return {
@@ -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: offer map_project with repos.',
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 Node path in the schedule no longer exists. 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,115 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { atomicWriteFile } from './atomic-file.mjs';
3
+ import { runtimeInfoPath } from './paths.mjs';
4
+
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.
10
+
11
+ export async function writeRuntimeInfo({
12
+ path = runtimeInfoPath(),
13
+ nodePath = process.execPath,
14
+ nodeVersion = process.version,
15
+ version = null,
16
+ now = () => new Date(),
17
+ writeFile = atomicWriteFile
18
+ } = {}) {
19
+ const info = {
20
+ nodePath: String(nodePath || ''),
21
+ nodeVersion: String(nodeVersion || ''),
22
+ installedAt: now().toISOString(),
23
+ version: version ? String(version) : null
24
+ };
25
+ await writeFile(path, `${JSON.stringify(info, null, 2)}\n`);
26
+ return info;
27
+ }
28
+
29
+ export async function loadRuntimeInfo({
30
+ path = runtimeInfoPath(),
31
+ readFile = fs.readFile
32
+ } = {}) {
33
+ let parsed;
34
+ try {
35
+ parsed = JSON.parse(await readFile(path, 'utf8'));
36
+ } catch (error) {
37
+ if (error.code === 'ENOENT' || error instanceof SyntaxError) {
38
+ return null;
39
+ }
40
+ throw error;
41
+ }
42
+ if (!parsed || typeof parsed !== 'object' || typeof parsed.nodePath !== 'string' || !parsed.nodePath) {
43
+ return null;
44
+ }
45
+ return {
46
+ nodePath: parsed.nodePath,
47
+ nodeVersion: typeof parsed.nodeVersion === 'string' ? parsed.nodeVersion : '',
48
+ installedAt: typeof parsed.installedAt === 'string' ? parsed.installedAt : '',
49
+ version: typeof parsed.version === 'string' ? parsed.version : null
50
+ };
51
+ }
52
+
53
+ // ok: the recorded node is the current one (same file after symlinks).
54
+ // missing: the recorded node no longer exists.
55
+ // stale: it exists but differs from the current, working node.
56
+ export async function inspectNodePath(recordedPath, {
57
+ currentPath = process.execPath,
58
+ platform = process.platform,
59
+ exists = pathExists,
60
+ realpath = fs.realpath
61
+ } = {}) {
62
+ const recorded = String(recordedPath || '');
63
+ if (!recorded) {
64
+ return { status: 'unknown', recordedPath: '', currentPath };
65
+ }
66
+ if (!await exists(recorded)) {
67
+ return { status: 'missing', recordedPath: recorded, currentPath };
68
+ }
69
+ const [left, right] = await Promise.all([
70
+ realpath(recorded).catch(() => recorded),
71
+ realpath(currentPath).catch(() => currentPath)
72
+ ]);
73
+ const same = platform === 'win32'
74
+ ? String(left).toLowerCase() === String(right).toLowerCase()
75
+ : left === right;
76
+ if (same) {
77
+ return { status: 'ok', recordedPath: recorded, currentPath };
78
+ }
79
+ if (!await exists(currentPath)) {
80
+ // Not stale: there is no working node to switch to.
81
+ return { status: 'differs', recordedPath: recorded, currentPath };
82
+ }
83
+ return { status: 'stale', recordedPath: recorded, currentPath };
84
+ }
85
+
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
+ export function formatNodePathIssue(source, inspection) {
89
+ const { status, recordedPath, currentPath } = inspection || {};
90
+ if (source === 'reminder') {
91
+ 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.`;
93
+ }
94
+ 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.`;
96
+ }
97
+ return '';
98
+ }
99
+ 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.`;
101
+ }
102
+ 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.`;
104
+ }
105
+ return '';
106
+ }
107
+
108
+ async function pathExists(path) {
109
+ try {
110
+ await fs.access(path);
111
+ return true;
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
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,4 +1,7 @@
1
+ import { homedir } from 'node:os';
2
+ import { resolve } from 'node:path';
1
3
  import { ApiError, getProjects } from './api.mjs';
4
+ import { resolveGitRepoRoot } from './commit-workflow.mjs';
2
5
  import {
3
6
  configPath,
4
7
  loadLocalConfig,
@@ -34,17 +37,35 @@ export async function upsertProjectMapping({
34
37
  projectName,
35
38
  tickets = [],
36
39
  keywords = [],
40
+ repos = [],
37
41
  scope = 'user',
38
42
  confirm,
39
43
  cwd = process.cwd(),
40
- fetchProjects
44
+ fetchProjects,
45
+ resolveRepoRoot = resolveGitRepoRoot
41
46
  } = {}) {
42
47
  if (confirm !== true) {
43
48
  throw new Error('map_project requires confirm: true.');
44
49
  }
45
50
 
46
- if (!Array.isArray(tickets) || !tickets.length) {
47
- throw new Error('map_project requires at least one ticket prefix.');
51
+ const ticketList = Array.isArray(tickets) ? tickets : [];
52
+ const repoList = Array.isArray(repos) ? repos.filter((repo) => typeof repo === 'string' && repo.trim()) : [];
53
+ if (!ticketList.length && !repoList.length) {
54
+ throw new Error('map_project requires at least one ticket prefix or repo.');
55
+ }
56
+
57
+ // Commit drafts match a commit's git repo root, so a subfolder is stored as
58
+ // the root of the repo that contains it.
59
+ const repoRoots = [];
60
+ for (const repo of repoList) {
61
+ const trimmed = repo.trim();
62
+ const expanded = trimmed === '~' || /^~[\\/]/.test(trimmed) ? `${homedir()}${trimmed.slice(1)}` : trimmed;
63
+ const path = resolve(cwd, expanded);
64
+ try {
65
+ repoRoots.push(await resolveRepoRoot(path));
66
+ } catch (error) {
67
+ throw new Error(`${path} is not a git repository: ${error.message}`);
68
+ }
48
69
  }
49
70
 
50
71
  const projects = sanitizeProjects(await (fetchProjects || createMembershipProjectsFetcher())());
@@ -60,8 +81,9 @@ export async function upsertProjectMapping({
60
81
  cwd,
61
82
  scope,
62
83
  project: resolution.project,
63
- tickets,
64
- keywords
84
+ tickets: ticketList,
85
+ keywords,
86
+ repos: repoRoots
65
87
  });
66
88
 
67
89
  return {
@@ -72,8 +94,9 @@ export async function upsertProjectMapping({
72
94
  projects,
73
95
  summary: [
74
96
  `${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.'
97
+ result.mapping.tickets.length ? `Tickets: ${result.mapping.tickets.join(', ')}.` : 'Tickets: none.',
98
+ result.mapping.keywords.length ? `Keywords: ${result.mapping.keywords.join(', ')}.` : 'Keywords: none.',
99
+ result.mapping.repos?.length ? `Repos: ${result.mapping.repos.join(', ')}.` : 'Repos: none.'
77
100
  ].join('\n')
78
101
  };
79
102
  }
@@ -1,4 +1,3 @@
1
- import { CONFIG } from './config.mjs';
2
1
  import { getFreshToken, getUserIdFromJwt } from './auth.mjs';
3
2
  import {
4
3
  ApiError,
@@ -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);