logwork-cli 1.1.0 → 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 (70) hide show
  1. package/README.md +35 -9
  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 +157 -19
  8. package/commands/tui.mjs +1 -0
  9. package/docs/advanced.md +12 -2
  10. package/docs/mcp-setup.md +57 -15
  11. package/docs/operations-guide.md +37 -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-parser.mjs +23 -0
  25. package/lib/batch-workflow.mjs +201 -2
  26. package/lib/commit-workflow.mjs +278 -30
  27. package/lib/config.mjs +7 -0
  28. package/lib/diagnostics.mjs +53 -4
  29. package/lib/jira-api.mjs +25 -3
  30. package/lib/jira-workflow.mjs +36 -6
  31. package/lib/keychain.mjs +19 -12
  32. package/lib/logwork-config.mjs +64 -13
  33. package/lib/manual-apply-state.mjs +15 -0
  34. package/lib/manual-commands.mjs +2 -2
  35. package/lib/manual-ink-app.mjs +108 -1281
  36. package/lib/manual-ink-draft-actions.mjs +227 -0
  37. package/lib/manual-ink-interaction.mjs +306 -0
  38. package/lib/manual-ink-session.mjs +41 -0
  39. package/lib/manual-ink-task-editor.mjs +338 -0
  40. package/lib/manual-ink-wizard.mjs +520 -0
  41. package/lib/manual-mcp.mjs +27 -15
  42. package/lib/manual-repl.mjs +39 -3
  43. package/lib/mcp-server.mjs +63 -21
  44. package/lib/node-runtime.mjs +115 -0
  45. package/lib/npm-command.mjs +29 -0
  46. package/lib/paths.mjs +11 -1
  47. package/lib/project-mapping-workflow.mjs +30 -7
  48. package/lib/query-workflow.mjs +0 -1
  49. package/lib/reconciliation-workflow.mjs +1 -1
  50. package/lib/release-doctor.mjs +4 -2
  51. package/lib/reminder-config.mjs +38 -2
  52. package/lib/reminder-scheduler.mjs +54 -3
  53. package/lib/reminder-service.mjs +28 -3
  54. package/lib/reminder-workflow.mjs +118 -22
  55. package/lib/ro-api-diagnostics.mjs +109 -0
  56. package/lib/ro-api-entries.mjs +359 -0
  57. package/lib/ro-api-fields.mjs +290 -0
  58. package/lib/ro-api-http.mjs +68 -0
  59. package/lib/ro-api-projects.mjs +137 -0
  60. package/lib/ro-api-tasks.mjs +50 -0
  61. package/lib/ro-api-timesheet.mjs +413 -0
  62. package/lib/ro-delete-workflow.mjs +5 -8
  63. package/lib/ro-edit-workflow.mjs +66 -8
  64. package/lib/ro-entry-reader.mjs +49 -6
  65. package/lib/ro-resubmit-workflow.mjs +35 -21
  66. package/lib/terminal-auth-prompts.mjs +7 -4
  67. package/lib/ui.mjs +80 -59
  68. package/lib/update-service.mjs +5 -2
  69. package/package.json +11 -4
  70. package/skills/logwork/SKILL.md +6 -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,23 +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 });
168
+ // Check allowUnbooked and allowDuplicates before the preview is consumed,
169
+ // so the agent can retry the same batchId with the flag set.
170
+ const pending = resolveApprovedBatch({ cache: previews, batchId });
171
+ assertUnbookedAllowed(pending, allowUnbooked);
172
+ assertDuplicatesAllowed(pending, allowDuplicates);
165
173
  const approvedBatch = consumeApprovedBatch({ cache: previews, batchId });
166
- return formatToolResponse(await applyLogworkBatch({ batch: approvedBatch, confirm, allowUnbooked }));
174
+ return formatToolResponse(await applyLogworkBatch({ batch: approvedBatch, confirm, allowUnbooked, allowDuplicates }));
167
175
  }
168
176
  requirePreviewInput('log_work', { text });
169
177
  const preview = await previewLogworkBatch({ text, projectOverrides: projectOverrides || {}, typeOfWork, defaultWorklogTask });
@@ -203,7 +211,7 @@ server.registerTool('resubmit_entry', {
203
211
  annotations: WRITE,
204
212
  inputSchema: {
205
213
  logworkId: LOGWORK_ID_SCHEMA,
206
- hours: z.number().positive().optional().describe('Preview call only. New hours; defaults to the rejected entry hours.'),
214
+ hours: z.number().positive().max(24).optional().describe('Preview call only. New hours; defaults to the rejected entry hours.'),
207
215
  typeOfWork: TYPE_OF_WORK_SCHEMA.optional().describe('Preview call only. New type of work; defaults to the rejected entry value.'),
208
216
  noteToPm: z.string().max(500).optional().describe('Preview call only. Note to the project manager explaining the fix.'),
209
217
  description: z.string().max(1_000).optional().describe('Preview call only. New description; defaults to the rejected entry description.'),
@@ -257,8 +265,7 @@ server.registerTool('log_jira_work', {
257
265
  prunePreviewCache(jiraPreviews);
258
266
  if (confirm === true) {
259
267
  assertApplyOnly('log_jira_work', { text });
260
- const approvedBatch = consumeApprovedJiraBatch({ cache: jiraPreviews, batchId });
261
- return formatToolResponse(await applyJiraWorklogBatch({ batch: approvedBatch, confirm }));
268
+ return applyCachedJiraWork({ cache: jiraPreviews, batchId, confirm });
262
269
  }
263
270
  requirePreviewInput('log_jira_work', { text });
264
271
  const preview = await previewJiraWorklogBatch({ text });
@@ -304,18 +311,19 @@ server.registerTool('get_history', {
304
311
 
305
312
  server.registerTool('map_project', {
306
313
  title: 'Save a project mapping',
307
- 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.',
308
315
  annotations: { ...LOCAL_WRITE, idempotentHint: true },
309
316
  inputSchema: {
310
317
  projectMemberId: z.union([z.string(), z.number()]).optional().describe('RO projectMemberId from list_projects or preview setupSuggestions.'),
311
318
  projectName: z.string().optional().describe('Project name to match against the user\'s memberships when projectMemberId is not given.'),
312
- 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.'),
313
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.'),
314
322
  scope: z.enum(['user', 'project']).optional().describe('user (default): ~/.logwork-cli/.logwork.json for this machine. project: .logwork.json in the current repo.'),
315
323
  confirm: z.boolean().describe('Must be true, and only after explicit user approval.')
316
324
  }
317
- }, withAuthRequiredHandling(async ({ projectMemberId, projectName, tickets, keywords = [], scope = 'user', confirm }) => {
318
- 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 });
319
327
  return formatToolResponse(result);
320
328
  }));
321
329
 
@@ -340,15 +348,19 @@ server.registerTool('login', {
340
348
 
341
349
  server.registerTool('reminder', {
342
350
  title: 'Logwork reminder',
343
- 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.',
344
352
  annotations: LOCAL_WRITE,
345
353
  inputSchema: {
346
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.'),
347
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.'),
348
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.'),
349
358
  confirm: z.boolean().optional().describe('Required (true) for enable, disable and test, after explicit user approval.')
350
359
  }
351
- }, 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
+ }
352
364
  if (action === 'status') {
353
365
  return formatToolResponse(await getLogworkReminderStatus());
354
366
  }
@@ -362,7 +374,7 @@ server.registerTool('reminder', {
362
374
  throw new Error('target is not valid when disabling the reminder.');
363
375
  }
364
376
  if (action === 'enable') {
365
- return formatToolResponse(await enableLogworkReminder({ time, target, confirm }));
377
+ return formatToolResponse(await enableLogworkReminder({ time, target, repos, confirm }));
366
378
  }
367
379
  if (action === 'disable') {
368
380
  return formatToolResponse(await disableLogworkReminder({ confirm }));
@@ -536,7 +548,7 @@ export function resolveApprovedBatch({
536
548
 
537
549
  const cached = getCachedPreviewRecord(cache, batchId, now);
538
550
  if (!cached) {
539
- throw new Error('No cached log_work preview for this batchId (expired after 1 hour, already applied, or the server restarted). Preview again and apply using the returned batchId.');
551
+ throw new Error('No cached log_work preview for this batchId (expired after 1 hour, already used by an earlier confirm call, or the server restarted; an earlier confirm may have failed without writing, so check get_logwork before assuming it was applied). Preview again and apply using the returned batchId.');
540
552
  }
541
553
 
542
554
  if (batch && createPreviewFingerprint(batch) !== cached.fingerprint) {
@@ -546,6 +558,20 @@ export function resolveApprovedBatch({
546
558
  return clonePreview(cached.preview);
547
559
  }
548
560
 
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) {
569
+ const unbooked = (batch?.entries || []).filter((entry) => entry.requiresAllowUnbooked);
570
+ if (unbooked.length && allowUnbooked !== true) {
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.`);
572
+ }
573
+ }
574
+
549
575
  export function consumeApprovedBatch({
550
576
  cache,
551
577
  batchId,
@@ -562,6 +588,19 @@ export function consumeApprovedBatch({
562
588
  return approvedBatch;
563
589
  }
564
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
+
565
604
  export function resolveApprovedJiraBatch({
566
605
  cache,
567
606
  batchId,
@@ -578,7 +617,7 @@ export function resolveApprovedJiraBatch({
578
617
 
579
618
  const cached = getCachedPreviewRecord(cache, batchId, now);
580
619
  if (!cached) {
581
- throw new Error('No cached log_jira_work preview for this batchId (expired after 1 hour, already applied, or the server restarted). Preview again and apply using the returned batchId.');
620
+ throw new Error('No cached log_jira_work preview for this batchId (expired after 1 hour, already used by an earlier confirm call, or the server restarted; an earlier confirm may have failed without writing, so check get_logwork before assuming it was applied). Preview again and apply using the returned batchId.');
582
621
  }
583
622
 
584
623
  if (batch && createJiraPreviewFingerprint(batch) !== cached.fingerprint) {
@@ -620,7 +659,7 @@ export function resolveApprovedRoEditPreview({
620
659
 
621
660
  const cached = getCachedPreviewRecord(cache, previewId, now);
622
661
  if (!cached) {
623
- throw new Error(`No cached ${toolName} preview for this previewId (expired after 1 hour, already applied, or the server restarted). Preview again before applying.`);
662
+ throw new Error(`No cached ${toolName} preview for this previewId (expired after 1 hour, already used by an earlier confirm call, or the server restarted; an earlier confirm may have failed without writing, so check get_logwork before assuming it was applied). Preview again before applying.`);
624
663
  }
625
664
  if (preview && createRoEditPreviewFingerprint(preview) !== cached.fingerprint) {
626
665
  throw new Error(`Approved ${toolName} preview changed after preview. Preview again before applying.`);
@@ -673,7 +712,10 @@ export function createPreviewFingerprint(preview = {}) {
673
712
  confidence: numberOrNull(entry?.confidence),
674
713
  matchedProject: projectFingerprint(entry?.matchedProject),
675
714
  booked: booleanOrNull(entry?.booked),
676
- requiresAllowUnbooked: booleanOrNull(entry?.requiresAllowUnbooked)
715
+ requiresAllowUnbooked: booleanOrNull(entry?.requiresAllowUnbooked),
716
+ possibleDuplicate: entry?.possibleDuplicate
717
+ ? textOrNull(entry.possibleDuplicate.logworkId ?? entry.possibleDuplicate.entryId)
718
+ : null
677
719
  }))
678
720
  });
679
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
+ }
@@ -0,0 +1,29 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+
4
+ // How to start npm as a child process on every platform.
5
+ // On Windows, npm is npm.cmd: spawn('npm') fails with ENOENT, and since the
6
+ // Node fix for CVE-2024-27980 spawn('npm.cmd') without a shell fails with
7
+ // EINVAL. Running npm's own npm-cli.js with the current node avoids the shell;
8
+ // when it cannot be found, fall back to npm.cmd through cmd.exe.
9
+ export function npmInvocation(args, {
10
+ platform = process.platform,
11
+ execPath = process.execPath,
12
+ env = process.env,
13
+ exists = existsSync
14
+ } = {}) {
15
+ if (platform !== 'win32') {
16
+ return { command: 'npm', args, shell: false };
17
+ }
18
+ const candidates = [
19
+ env.npm_execpath && /npm-cli\.js$/i.test(env.npm_execpath) ? env.npm_execpath : null,
20
+ join(dirname(execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js')
21
+ ].filter(Boolean);
22
+ const cli = candidates.find((path) => exists(path));
23
+ if (cli) {
24
+ return { command: execPath, args: [cli, ...args], shell: false };
25
+ }
26
+ // cmd.exe joins the arguments unquoted; the arguments used here have no
27
+ // spaces or shell metacharacters.
28
+ return { command: 'npm.cmd', args, shell: true };
29
+ }
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,8 +52,17 @@ 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
- return resolve(homedir(), LEGACY_CONFIG_FILE_NAME);
62
+ // A custom home (tests, separate profiles) keeps its state isolated from
63
+ // the real ~/.logwork-helper.json.
64
+ const custom = customHelperHome();
65
+ return custom ? resolve(custom, LEGACY_CONFIG_FILE_NAME) : resolve(homedir(), LEGACY_CONFIG_FILE_NAME);
56
66
  }
57
67
 
58
68
  // Project-scope mappings live in the repo the user works in. A repo that still
@@ -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,6 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { readdir, readFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
+ import { npmInvocation } from './npm-command.mjs';
4
5
 
5
6
  const PACKAGE_NAME = 'logwork-cli';
6
7
  const PACK_CACHE_PATH = '/private/tmp/logwork-cli-npm-cache';
@@ -113,10 +114,11 @@ export async function runCommandDefault(command, args = [], {
113
114
  ...process.env,
114
115
  ...(gitSafe ? { GIT_CONFIG_GLOBAL: '/dev/null' } : {})
115
116
  };
116
- const child = spawn(command, args, {
117
+ const invocation = command === 'npm' ? npmInvocation(args) : { command, args, shell: false };
118
+ const child = spawn(invocation.command, invocation.args, {
117
119
  cwd,
118
120
  env,
119
- shell: false,
121
+ shell: invocation.shell,
120
122
  stdio: ['ignore', 'pipe', 'pipe']
121
123
  });
122
124
  let stdout = '';