logwork-cli 1.0.0 → 1.1.1

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.
@@ -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
@@ -52,7 +52,10 @@ export function reminderStatePath() {
52
52
  }
53
53
 
54
54
  export function legacyUserConfigPath() {
55
- return resolve(homedir(), LEGACY_CONFIG_FILE_NAME);
55
+ // A custom home (tests, separate profiles) keeps its state isolated from
56
+ // the real ~/.logwork-helper.json.
57
+ const custom = customHelperHome();
58
+ return custom ? resolve(custom, LEGACY_CONFIG_FILE_NAME) : resolve(homedir(), LEGACY_CONFIG_FILE_NAME);
56
59
  }
57
60
 
58
61
  // Project-scope mappings live in the repo the user works in. A repo that still
@@ -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 = '';
@@ -1,6 +1,6 @@
1
1
  import { ApiError, deleteLogworkEntry } from './api.mjs';
2
2
  import { createResourceOptimiserSession } from './query-workflow.mjs';
3
- import { createF03LogworkReader } from './ro-entry-reader.mjs';
3
+ import { assertOwnUnlockedRoEntry, createF03LogworkReader, createRoEntryRevisionFingerprint } from './ro-entry-reader.mjs';
4
4
 
5
5
  const DELETABLE_STATUSES = new Set(['submitted', 'approved']);
6
6
 
@@ -11,6 +11,7 @@ export async function previewRoLogworkDelete({
11
11
  const reader = fetchLogwork || createF03LogworkReader();
12
12
  const current = await reader(assertIdentifier(logworkId, 'logworkId'));
13
13
  assertDeletableStatus(current);
14
+ assertOwnUnlockedRoEntry(current, 'delete');
14
15
 
15
16
  const preview = {
16
17
  previewId: `delete_${current.id}`,
@@ -106,10 +107,5 @@ function assertIdentifier(value, label) {
106
107
  }
107
108
 
108
109
  function createLogworkRevisionFingerprint(logwork) {
109
- return JSON.stringify({
110
- id: logwork.id,
111
- hours: logwork.hours,
112
- status: logwork.status,
113
- taskName: logwork.taskName
114
- });
110
+ return createRoEntryRevisionFingerprint(logwork);
115
111
  }
@@ -100,7 +100,7 @@ export async function applyRoLogworkEdit({
100
100
  const reader = fetchLogwork || createF03LogworkReader();
101
101
  const creator = submitCreate || createEntryWriter();
102
102
  const deleter = submitDelete || createDeleteWriter();
103
- const dayReader = fetchDayEntries || createDayEntriesReader();
103
+ const dayReader = fetchDayEntries || (injected ? null : createDayEntriesReader());
104
104
  const ledgerWriter = recordLedger === undefined
105
105
  ? (injected ? null : recordApplyResult)
106
106
  : recordLedger;
@@ -121,15 +121,27 @@ export async function applyRoLogworkEdit({
121
121
  needsProjectId: preview.updated.needsProjectId
122
122
  };
123
123
 
124
+ // Entry ids already on the target day, so a replacement found later by its
125
+ // values is never confused with an older identical entry.
126
+ const existingIds = await readDayEntryIds(dayReader, preview.updated.date);
127
+
124
128
  let createResult;
125
129
  try {
126
130
  createResult = await creator(createRequest);
127
131
  } catch (error) {
128
- error.message = `Could not create the replacement RO entry; the original entry ${preview.logworkId} is unchanged. ${error.message}`;
129
- error.applyLedger = await recordEditLedger({
130
- writer: ledgerWriter, preview, status: 'failed', entryStatus: 'failed', error: error.message, cwd
131
- });
132
- throw error;
132
+ // Writes are not retried, so a timeout is ambiguous: RO may have saved the
133
+ // entry. Look for it before reporting that nothing changed.
134
+ const savedId = await findCreatedEntryId({ dayReader, preview, existingIds });
135
+ if (savedId === null) {
136
+ error.message = existingIds
137
+ ? `Could not create the replacement RO entry; the original entry ${preview.logworkId} is unchanged. ${error.message}`
138
+ : `Could not create the replacement RO entry (${error.message}). The original entry ${preview.logworkId} is unchanged, but check ${preview.updated.date} (get_logwork or \`logwork query\`) in case RO saved a copy.`;
139
+ error.applyLedger = await recordEditLedger({
140
+ writer: ledgerWriter, preview, status: 'failed', entryStatus: 'failed', error: error.message, cwd
141
+ });
142
+ throw error;
143
+ }
144
+ createResult = { entry: { id: savedId } };
133
145
  }
134
146
 
135
147
  if (createResult?.dryRun) {
@@ -156,7 +168,7 @@ export async function applyRoLogworkEdit({
156
168
 
157
169
  let newLogworkId = createResult?.entry?.id ?? null;
158
170
  if (newLogworkId === null || newLogworkId === undefined) {
159
- newLogworkId = await findCreatedEntryId({ dayReader, preview });
171
+ newLogworkId = await findCreatedEntryId({ dayReader, preview, existingIds });
160
172
  }
161
173
  if (newLogworkId === null || newLogworkId === undefined) {
162
174
  const error = new Error(
@@ -172,6 +184,25 @@ export async function applyRoLogworkEdit({
172
184
  try {
173
185
  await deleter(preview.logworkId);
174
186
  } catch (error) {
187
+ // A failed DELETE may still have deleted the original (timeout). Roll the
188
+ // replacement back only when the original is confirmed to still exist;
189
+ // otherwise rolling back could leave neither entry.
190
+ const originalState = await originalEntryState({ dayReader, preview });
191
+ if (originalState === 'gone') {
192
+ return finishReplace({ reader, preview, newLogworkId, ledgerWriter, cwd });
193
+ }
194
+ if (originalState === 'unknown') {
195
+ const message = `Deleting the original RO entry ${preview.logworkId} failed (${error.message}) and its state could not be read. ` +
196
+ `The replacement entry ${newLogworkId} was kept. Check ${preview.original?.date || preview.updated.date} (get_logwork or \`logwork query\`); if both entries exist, delete ${preview.logworkId}.`;
197
+ const wrapped = new Error(message);
198
+ wrapped.cause = error;
199
+ wrapped.newLogworkId = newLogworkId;
200
+ wrapped.rolledBack = false;
201
+ wrapped.applyLedger = await recordEditLedger({
202
+ writer: ledgerWriter, preview, status: 'partial', entryStatus: 'failed', error: message, newLogworkId, cwd
203
+ });
204
+ throw wrapped;
205
+ }
175
206
  const rollback = await rollbackCreatedEntry({ deleter, newLogworkId });
176
207
  const message = rollback.ok
177
208
  ? `Could not delete the original RO entry ${preview.logworkId} (${error.message}). The replacement entry ${newLogworkId} was rolled back; nothing changed.`
@@ -193,6 +224,10 @@ export async function applyRoLogworkEdit({
193
224
  throw wrapped;
194
225
  }
195
226
 
227
+ return finishReplace({ reader, preview, newLogworkId, ledgerWriter, cwd });
228
+ }
229
+
230
+ async function finishReplace({ reader, preview, newLogworkId, ledgerWriter, cwd }) {
196
231
  let verified;
197
232
  try {
198
233
  verified = sanitizeLogwork(await reader(newLogworkId));
@@ -483,11 +518,34 @@ function changeFingerprint(change) {
483
518
  };
484
519
  }
485
520
 
486
- async function findCreatedEntryId({ dayReader, preview }) {
521
+ async function readDayEntryIds(dayReader, date) {
522
+ if (!dayReader) {
523
+ return null;
524
+ }
525
+ try {
526
+ return new Set((await dayReader(date) || []).map((entry) => String(entry.id)));
527
+ } catch {
528
+ return null;
529
+ }
530
+ }
531
+
532
+ async function originalEntryState({ dayReader, preview }) {
533
+ const ids = await readDayEntryIds(dayReader, preview.original?.date || preview.updated.date);
534
+ if (!ids) {
535
+ return 'unknown';
536
+ }
537
+ return ids.has(String(preview.logworkId)) ? 'present' : 'gone';
538
+ }
539
+
540
+ async function findCreatedEntryId({ dayReader, preview, existingIds = null }) {
541
+ if (!dayReader) {
542
+ return null;
543
+ }
487
544
  try {
488
545
  const entries = await dayReader(preview.updated.date);
489
546
  const candidates = (entries || []).filter((entry) => (
490
547
  String(entry.id) !== String(preview.logworkId) &&
548
+ (!existingIds || !existingIds.has(String(entry.id))) &&
491
549
  String(entry.worklogTaskId) === String(preview.updated.worklogTaskId) &&
492
550
  Number(entry.hours) === Number(preview.updated.hours) &&
493
551
  (entry.description || '') === (preview.updated.description || '') &&
@@ -18,7 +18,12 @@ export function createF03LogworkReader() {
18
18
  if (!f03) {
19
19
  throw new Error(`RO logwork ${logworkId} was not found on ${legacy.date}; it may have been deleted.`);
20
20
  }
21
- projectsPromise ||= getProjects(token, userId);
21
+ // Cache only a successful load: a cached rejection (say a 401) would make
22
+ // the retry after a forced login fail the same way.
23
+ projectsPromise ||= getProjects(token, userId).catch((error) => {
24
+ projectsPromise = null;
25
+ throw error;
26
+ });
22
27
  const projects = await projectsPromise;
23
28
  const projectMemberId = f03.projectMemberId ?? legacy.projectMemberId;
24
29
  const project = projects.find((item) => String(item.projectMemberId) === String(projectMemberId));
@@ -54,3 +59,36 @@ function withoutEmpty(record = {}) {
54
59
  value !== undefined && value !== null && value !== ''
55
60
  )));
56
61
  }
62
+
63
+ // Guards shared by delete and resubmit (edit has its own, stricter set): the
64
+ // entry must be the user's own, not synced from Jira, and on an unlocked day.
65
+ export function assertOwnUnlockedRoEntry(logwork, action) {
66
+ if (
67
+ logwork?.currentUserId !== undefined &&
68
+ logwork?.createdBy !== undefined &&
69
+ String(logwork.currentUserId) !== String(logwork.createdBy)
70
+ ) {
71
+ throw new Error(`Cannot ${action} another user's RO logwork.`);
72
+ }
73
+ const jiraId = logwork?.jiraLogworkId;
74
+ if ((jiraId !== null && jiraId !== undefined && String(jiraId).trim() !== '') || String(logwork?.source || '').toLowerCase() === 'jira') {
75
+ throw new Error(`This RO logwork was created by Jira; ${action} it in Jira so both systems stay in step.`);
76
+ }
77
+ if (logwork?.dayLocked) {
78
+ throw new Error(`Logwork for ${logwork.date || 'this day'} is locked on Resource Optimiser; cannot ${action} it.`);
79
+ }
80
+ }
81
+
82
+ // What a preview was approved against; apply refuses if any of it changed.
83
+ export function createRoEntryRevisionFingerprint(logwork) {
84
+ return JSON.stringify({
85
+ id: logwork.id,
86
+ date: logwork.date ?? null,
87
+ hours: logwork.hours,
88
+ status: logwork.status,
89
+ taskName: logwork.taskName,
90
+ worklogTaskId: logwork.worklogTaskId ?? null,
91
+ typeOfWork: logwork.typeOfWork ?? null,
92
+ description: logwork.description ?? null
93
+ });
94
+ }
@@ -1,6 +1,6 @@
1
1
  import { ApiError, resubmitLogworkEntry } from './api.mjs';
2
2
  import { createResourceOptimiserSession } from './query-workflow.mjs';
3
- import { createF03LogworkReader } from './ro-entry-reader.mjs';
3
+ import { assertOwnUnlockedRoEntry, createF03LogworkReader, createRoEntryRevisionFingerprint } from './ro-entry-reader.mjs';
4
4
 
5
5
  export async function previewRoLogworkResubmit({
6
6
  logworkId,
@@ -15,31 +15,40 @@ export async function previewRoLogworkResubmit({
15
15
  if (String(current.status || '').toLowerCase() !== 'rejected') {
16
16
  throw new Error('Only rejected RO logwork entries can be resubmitted through this flow.');
17
17
  }
18
+ assertOwnUnlockedRoEntry(current, 'resubmit');
18
19
 
19
20
  const effortHours = hours === undefined ? current.hours : Number(hours);
20
- if (!Number.isFinite(effortHours) || effortHours <= 0) {
21
- throw new Error('hours must be a positive number.');
21
+ if (!Number.isFinite(effortHours) || effortHours <= 0 || effortHours > 24) {
22
+ throw new Error('hours must be a positive number up to 24.');
22
23
  }
23
24
 
24
- const previewId = `resubmit_${current.id}_${effortHours}`;
25
+ const updated = {
26
+ hours: effortHours,
27
+ typeOfWork: typeOfWork || current.typeOfWork || 'other',
28
+ noteToPm: noteToPm ?? current.noteToPm ?? '',
29
+ description: description ?? current.description ?? ''
30
+ };
31
+ const originalFingerprint = createLogworkRevisionFingerprint(current);
32
+ // Every field that will be sent is part of the id, so two different
33
+ // previews of the same entry never share (and overwrite) one cache slot.
34
+ const previewId = `resubmit_${current.id}_${hash(JSON.stringify({ updated, originalFingerprint }))}`;
25
35
  const preview = {
26
36
  previewId,
27
37
  status: 'ready',
28
38
  logworkId: current.id,
29
39
  original: current,
30
- updated: {
31
- hours: effortHours,
32
- typeOfWork: typeOfWork || current.typeOfWork || 'other',
33
- noteToPm: noteToPm ?? current.noteToPm ?? '',
34
- description: description ?? current.description ?? ''
35
- },
36
- originalFingerprint: createLogworkRevisionFingerprint(current)
40
+ updated,
41
+ originalFingerprint
37
42
  };
43
+ const changeLine = (label, from, to) => (String(from ?? '') === String(to ?? '') ? `- ${label}: ${to || '(empty)'}` : `- ${label}: ${from || '(empty)'} -> ${to || '(empty)'}`);
38
44
  preview.summary = [
39
45
  'RO logwork resubmit preview:',
40
- `- Entry: ${current.id}`,
41
- `- Hours: ${current.hours} -> ${effortHours}`,
42
- `- Status: rejected -> approved (after resubmit)`
46
+ `- Entry: ${current.id} (${current.date || 'unknown date'}, ${current.taskName || 'unknown task'})`,
47
+ changeLine('Hours', current.hours, effortHours),
48
+ changeLine('Type of work', current.typeOfWork, updated.typeOfWork),
49
+ changeLine('Description', current.description, updated.description),
50
+ `- Note to PM: ${updated.noteToPm || '(none)'}`,
51
+ '- Status: rejected -> resubmitted'
43
52
  ].join('\n');
44
53
  return preview;
45
54
  }
@@ -125,10 +134,14 @@ function assertIdentifier(value, label) {
125
134
  }
126
135
 
127
136
  function createLogworkRevisionFingerprint(logwork) {
128
- return JSON.stringify({
129
- id: logwork.id,
130
- hours: logwork.hours,
131
- status: logwork.status,
132
- taskName: logwork.taskName
133
- });
137
+ return createRoEntryRevisionFingerprint(logwork);
138
+ }
139
+
140
+ function hash(value) {
141
+ let hashValue = 2166136261;
142
+ for (let index = 0; index < value.length; index += 1) {
143
+ hashValue ^= value.charCodeAt(index);
144
+ hashValue = Math.imul(hashValue, 16777619);
145
+ }
146
+ return (hashValue >>> 0).toString(36);
134
147
  }
@@ -5,6 +5,7 @@ import { atomicWriteFile } from './atomic-file.mjs';
5
5
  import { redactText } from './auth-redaction.mjs';
6
6
  import { fetchWithPolicy, redactedExcerpt } from './http.mjs';
7
7
  import { readPackageInfo } from './package-info.mjs';
8
+ import { npmInvocation } from './npm-command.mjs';
8
9
  import { helperHome, updateStatePath } from './paths.mjs';
9
10
 
10
11
  export const UPDATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
@@ -187,7 +188,7 @@ export async function installUpdate({
187
188
  };
188
189
  }
189
190
 
190
- const executable = process.platform === 'win32' ? 'npm.cmd' : 'npm';
191
+ const executable = 'npm';
191
192
  const args = [
192
193
  'exec',
193
194
  '--yes',
@@ -311,7 +312,9 @@ function requireSemver(value, label) {
311
312
 
312
313
  function runUpdateCommand(executable, args, { stdio = 'pipe' } = {}) {
313
314
  return new Promise((resolveValue, reject) => {
314
- const child = spawn(executable, args, {
315
+ const invocation = executable === 'npm' ? npmInvocation(args) : { command: executable, args, shell: false };
316
+ const child = spawn(invocation.command, invocation.args, {
317
+ shell: invocation.shell,
315
318
  cwd: helperHome(),
316
319
  env: process.env,
317
320
  stdio: stdio === 'inherit' ? 'inherit' : ['ignore', 'pipe', 'pipe']
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "logwork-cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "Log work to Resource Optimiser and Jira from the terminal, an MCP server for AI IDEs, and a terminal UI.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -38,6 +38,7 @@
38
38
  "bin/",
39
39
  "commands/",
40
40
  "lib/",
41
+ "skills/",
41
42
  "docs/",
42
43
  "examples/mcp/",
43
44
  "setup.sh",
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: logwork
3
+ description: Log work hours to Resource Optimiser (RO) and Jira from the user's own git commits with the logwork MCP tools. Use when the user asks to log work, fill in logwork or timesheets for a day or week, check missing hours, or fix existing RO entries.
4
+ ---
5
+
6
+ # Logwork from commits
7
+
8
+ Goal: every logged line is backed by real work (the user's commits or what the user tells you), uses a valid RO worklog task, has a clear description, and fits the day's booked hours.
9
+
10
+ ## Steps
11
+
12
+ 1. **Find the gaps.** Call `get_logwork` for the period. A day/project still needs time when booked > logged. Skip days that are already full.
13
+ 2. **Collect the evidence.** Call `draft_from_commits` with the same period and `repos` set to every repo the user worked in (the open workspace folders; ask the user for others). It groups the user's commits per day by repo, ticket and kind of work, suggests a worklog task per group, and splits each day's remaining RO hours across the groups. Treat its hours as a starting point, not a fact.
14
+ 3. **Check the worklog tasks.** For each project involved, call `list_tasks(projectId)`. Keep the suggested task when it exists in that catalog; otherwise pick the catalog task whose description matches the work.
15
+ 4. **Write the descriptions.** One line per piece of work: what was done and the outcome, with ticket keys, in English, under about 200 characters, no `|`. Rewrite vague commit subjects ("wip", "fix", "update"); use `get_jira_issue` to read the ticket when the subject alone is unclear.
16
+ 5. **Set the hours.**
17
+ - Stay within the remaining booked hours per day and project unless the user says otherwise. The draft splits the day's total; use each day's `remainingByProject` to move hours to the project the work belongs to.
18
+ - If `draft_from_commits` returns `authRequired`, call `login` first; do not guess hours without RO.
19
+ - Use 0.5h steps.
20
+ - Do not log weekends or days without a booking unless the user asks.
21
+ - Work without commits (meetings, reviews, support) only comes from the user; ask, then log it as `Other Activities` or `Code Review & Refactoring`.
22
+ 6. **Resolve projects.** Ticket prefixes and mappings pick the project. If a line stays unresolved, ask the user and offer `map_project` so it is automatic next time.
23
+ 7. **Get approval, then write.**
24
+ - Show the final block to the user.
25
+ - On approval, call `log_work(text)` and show its preview summary.
26
+ - Then call `log_work(batchId, confirm: true)`.
27
+ - If the user also logs Jira, do the same with `log_jira_work`. Jira needs exactly one issue key per line.
28
+
29
+ ## Line format
30
+
31
+ ```text
32
+ Friday, 06 Nov 2026
33
+ +2 Feature Development | Implement SSO login with ADFS (STM-4)
34
+ +1 Bug Fix | Fix date picker timezone offset (SAP-42)
35
+ ```
36
+
37
+ `+<hours> <worklog task> | <description>`. The task is an RO catalog name or `#<id>`.
38
+
39
+ ## Rules
40
+
41
+ - Never invent work that is not in the commits or in what the user told you. When unsure, ask.
42
+ - Never set `confirm: true` without the user's explicit approval of that preview.
43
+ - Never ask for or pass passwords, OTPs or tokens. On `authRequired`, call `login` and let the user finish in the terminal.
44
+ - To fix an existing entry, use `edit_entry` (submitted or approved entries) or `resubmit_entry` (rejected entries). Do not log a duplicate.