logwork-cli 1.1.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.
package/README.md CHANGED
@@ -30,16 +30,26 @@ logwork setup
30
30
 
31
31
  ## Upgrading from logwork-helper
32
32
 
33
- The package was renamed from `logwork-helper` to `logwork-cli`, and the `logwork-helper` command no longer exists. To upgrade:
33
+ The package was renamed from `logwork-helper` to `logwork-cli`, and the `logwork-helper` command no longer exists. Both packages install a `logwork` command, so remove the old one first (otherwise npm stops with `EEXIST`):
34
34
 
35
35
  ```bash
36
+ npm uninstall -g logwork-helper
36
37
  npm install -g logwork-cli
37
38
  logwork setup
38
39
  ```
39
40
 
40
41
  Most of the move is automatic:
41
42
 
42
- - **Old global package:** `logwork setup` removes the global `logwork-helper` package, so its `logwork` command does not conflict with the new one.
43
+ - **Old global package:** if it is still installed, `logwork setup` removes the global `logwork-helper` package before linking. You can also run `npx logwork-cli@latest setup` without uninstalling first.
44
+ - **`EEXIST ... bin/logwork` even after uninstalling:** the old package was linked from `~/.logwork-helper`. If that folder was deleted first, `npm uninstall` cannot remove its links. Remove the broken links, then install again:
45
+
46
+ ```bash
47
+ P="$(npm prefix -g)"
48
+ rm -f "$P/bin/logwork" "$P"/bin/logwork-helper* "$P/lib/node_modules/logwork-helper"
49
+ npm install -g logwork-cli && logwork setup
50
+ ```
51
+
52
+ `npx logwork-cli@latest setup` also removes those broken links (macOS).
43
53
  - **Local state:** on the first run, settings, project mappings, drafts, apply history and the reminder config are copied from `~/.logwork-helper` to `~/.logwork-cli`. The old directory is left in place.
44
54
  - **Login sessions:** your RO and Jira sessions are copied from the old Keychain / Credential Manager entry (`logwork-helper`) to the new one (`logwork-cli`), so you do not need to log in again.
45
55
  - **Repo mappings:** a repo that has `.logwork-helper.json` keeps using it until a `.logwork.json` exists.
@@ -4,6 +4,7 @@ import { delimiter, dirname, join, relative, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { redactText } from '../lib/auth-redaction.mjs';
6
6
  import { credentialStoreLabel } from '../lib/credential-store.mjs';
7
+ import { npmInvocation } from '../lib/npm-command.mjs';
7
8
  import { helperHome } from '../lib/paths.mjs';
8
9
  import {
9
10
  MCP_SERVER_NAME,
@@ -59,7 +60,7 @@ export async function installUserRuntime({
59
60
  sourceDir,
60
61
  targetDir,
61
62
  mkdirFn = fs.mkdir,
62
- copyFn = copyDirectory,
63
+ copyFn = copyRuntimeFiles,
63
64
  cleanupFn = sanitizeRuntimeMetadata,
64
65
  installFn = installDependencies,
65
66
  linkFn = linkGlobalBinaries,
@@ -103,6 +104,40 @@ async function refreshReminderSchedule(targetDir) {
103
104
  }
104
105
  }
105
106
 
107
+ // Copies only what the npm package ships (package.json "files", plus the
108
+ // manifest and lockfile). The target is also the state directory, so a git
109
+ // checkout's .env files, tests, archives or a repo-scope .logwork.json must
110
+ // never land there.
111
+ export async function copyRuntimeFiles(sourceDir, targetDir) {
112
+ const manifest = JSON.parse(await fs.readFile(join(sourceDir, 'package.json'), 'utf8'));
113
+ const listed = Array.isArray(manifest.files) ? manifest.files : [];
114
+ const names = ['package.json', 'package-lock.json', 'npm-shrinkwrap.json', ...listed.map((item) => String(item).replace(/[\\/]+$/, ''))];
115
+ for (const name of [...new Set(names)]) {
116
+ const sourcePath = join(sourceDir, name);
117
+ let stat;
118
+ try {
119
+ stat = await fs.stat(sourcePath);
120
+ } catch (error) {
121
+ if (error.code === 'ENOENT') {
122
+ // A lockfile from an earlier install that no longer matches this
123
+ // package.json would make `npm ci` fail; let npm install write a new one.
124
+ if (name === 'package-lock.json') {
125
+ await fs.rm(join(targetDir, name), { force: true });
126
+ }
127
+ continue;
128
+ }
129
+ throw error;
130
+ }
131
+ if (stat.isDirectory()) {
132
+ await fs.mkdir(join(targetDir, name), { recursive: true });
133
+ await copyDirectory(sourcePath, targetDir, sourceDir);
134
+ } else {
135
+ await fs.mkdir(dirname(join(targetDir, name)), { recursive: true });
136
+ await fs.copyFile(sourcePath, join(targetDir, name));
137
+ }
138
+ }
139
+ }
140
+
106
141
  async function copyDirectory(sourceDir, targetDir, rootDir) {
107
142
  const entries = await fs.readdir(sourceDir, { withFileTypes: true });
108
143
 
@@ -139,7 +174,8 @@ function installDependencies(targetDir) {
139
174
 
140
175
  function runCommand(command, args, options = {}) {
141
176
  return new Promise((resolveValue, reject) => {
142
- const child = spawn(command, args, options);
177
+ const invocation = command === 'npm' ? npmInvocation(args) : { command, args, shell: false };
178
+ const child = spawn(invocation.command, invocation.args, { ...options, shell: invocation.shell });
143
179
 
144
180
  child.on('error', reject);
145
181
  child.on('close', (code) => {
@@ -243,9 +279,73 @@ function isStaleSelfDependency(value) {
243
279
  return typeof value === 'string' && /^file:.*logwork-(helper|cli).*\.tgz$/i.test(value);
244
280
  }
245
281
 
282
+ const LEGACY_GLOBAL_LINKS = [
283
+ 'bin/logwork',
284
+ 'bin/logwork-helper',
285
+ 'bin/logwork-helper-install',
286
+ 'bin/logwork-helper-manual',
287
+ 'bin/logwork-helper-mcp',
288
+ 'bin/logwork-helper-setup-user',
289
+ 'lib/node_modules/logwork-helper'
290
+ ];
291
+
292
+ // The old package was installed with `npm link` from ~/.logwork-helper. When
293
+ // that folder is deleted first, its global links dangle: `npm rm -g` cannot
294
+ // read the package any more and leaves bin/logwork behind, which then blocks
295
+ // `npm install -g logwork-cli` and `npm link` with EEXIST. Remove only links
296
+ // that point into a logwork-helper path that no longer exists.
297
+ export async function removeDanglingLegacyLinks(globalPrefix, {
298
+ platform = process.platform,
299
+ lstatFn = fs.lstat,
300
+ statFn = fs.stat,
301
+ readlinkFn = fs.readlink,
302
+ unlinkFn = fs.unlink
303
+ } = {}) {
304
+ const removed = [];
305
+ if (!globalPrefix || platform === 'win32') {
306
+ return removed;
307
+ }
308
+ for (const relativePath of LEGACY_GLOBAL_LINKS) {
309
+ const path = join(globalPrefix, relativePath);
310
+ try {
311
+ if (!(await lstatFn(path)).isSymbolicLink()) {
312
+ continue;
313
+ }
314
+ if (!/logwork-helper/.test(await readlinkFn(path))) {
315
+ continue;
316
+ }
317
+ try {
318
+ await statFn(path);
319
+ continue;
320
+ } catch {
321
+ // Dangling: the target is gone.
322
+ }
323
+ await unlinkFn(path);
324
+ removed.push(path);
325
+ } catch {
326
+ // Missing or unreadable: nothing to clean.
327
+ }
328
+ }
329
+ return removed;
330
+ }
331
+
332
+ function readNpmGlobalPrefix(cwd) {
333
+ return new Promise((resolveValue) => {
334
+ const invocation = npmInvocation(['prefix', '-g']);
335
+ const child = spawn(invocation.command, invocation.args, { cwd, shell: invocation.shell, stdio: ['ignore', 'pipe', 'ignore'] });
336
+ let stdout = '';
337
+ child.stdout.setEncoding('utf8');
338
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
339
+ child.on('error', () => resolveValue(''));
340
+ child.on('close', (code) => resolveValue(code === 0 ? stdout.trim() : ''));
341
+ });
342
+ }
343
+
246
344
  export async function linkGlobalBinaries(targetDir, {
247
345
  runner = runCommand,
248
- commandFinder = findCommandOnPath
346
+ commandFinder = findCommandOnPath,
347
+ prefixReader = readNpmGlobalPrefix,
348
+ legacyLinkCleaner = removeDanglingLegacyLinks
249
349
  } = {}) {
250
350
  // Best effort: drop the pre-rename global package so its `logwork` bin does
251
351
  // not block linking this one. A no-op when it is not installed.
@@ -253,6 +353,10 @@ export async function linkGlobalBinaries(targetDir, {
253
353
  cwd: targetDir,
254
354
  stdio: 'ignore'
255
355
  }).catch(() => {});
356
+ await Promise.resolve()
357
+ .then(() => prefixReader(targetDir))
358
+ .then((prefix) => legacyLinkCleaner(prefix))
359
+ .catch(() => []);
256
360
 
257
361
  try {
258
362
  await runner('npm', ['link'], {
package/docs/mcp-setup.md CHANGED
@@ -107,7 +107,7 @@ Previews expire after 1 hour. They are also lost when the server restarts.
107
107
 
108
108
  ### Tool Name Changes From logwork-helper
109
109
 
110
- | logwork-helper (before 0.3.0) | logwork-cli |
110
+ | logwork-helper (0.2.x and earlier) | logwork-cli |
111
111
  |---|---|
112
112
  | `preview_logwork_batch`, `apply_logwork_batch` | `log_work` |
113
113
  | `query_logwork` | `get_logwork` |
@@ -19,6 +19,8 @@ npm install -g logwork-cli
19
19
  logwork setup
20
20
  ```
21
21
 
22
+ Upgrading from `logwork-helper`: run `npm uninstall -g logwork-helper` first. Both packages provide a `logwork` command, and npm refuses to overwrite it (`EEXIST`). If `~/.logwork-helper` was already deleted, the uninstall leaves broken links behind; remove them with `P="$(npm prefix -g)"; rm -f "$P/bin/logwork" "$P"/bin/logwork-helper* "$P/lib/node_modules/logwork-helper"`, or run `npx logwork-cli@latest setup`, which removes them.
23
+
22
24
  Authenticate each target from Terminal:
23
25
 
24
26
  ```bash
@@ -1,6 +1,8 @@
1
1
  const DAY_HEADING_RE = /^(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday),\s+(\d{2})\s+([A-Za-z]{3})\s+(\d{4})$/;
2
2
  const ENTRY_RE = /^\+(\d+(?:\.\d+)?)\s+(.+)$/;
3
3
  const TICKET_RE = /\b[A-Z][A-Z0-9]+-\d+\b/g;
4
+ const MAX_DAY_HOURS = 24;
5
+ const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
4
6
 
5
7
  const MONTHS = new Map([
6
8
  ['jan', 1],
@@ -67,6 +69,18 @@ export function parseWeeklyLogText(text) {
67
69
  });
68
70
  continue;
69
71
  }
72
+ // RO rejects more than 24h; catching it here keeps a batch from failing
73
+ // halfway after earlier entries were written.
74
+ const dayTotal = entries.filter((item) => item.date === currentDate).reduce((sum, item) => sum + item.hours, 0) + hours;
75
+ if (hours > MAX_DAY_HOURS || dayTotal > MAX_DAY_HOURS) {
76
+ errors.push({
77
+ line: lineNumber,
78
+ message: hours > MAX_DAY_HOURS
79
+ ? `Hours ${entry[1]} exceed ${MAX_DAY_HOURS}h for one entry.`
80
+ : `Entries for ${currentDate} add up to ${dayTotal}h, more than ${MAX_DAY_HOURS}h in one day.`
81
+ });
82
+ continue;
83
+ }
70
84
 
71
85
  const text = entry[2].trim();
72
86
  const { taskName, description, taskExplicit } = splitTaskAndDescription(text);
@@ -137,6 +151,15 @@ function parseHeadingDate(match, lineNumber, errors) {
137
151
  return null;
138
152
  }
139
153
 
154
+ const weekday = WEEKDAYS[date.getUTCDay()];
155
+ if (weekday !== match[1]) {
156
+ errors.push({
157
+ line: lineNumber,
158
+ message: `Date heading says ${match[1]} but ${match[2]} ${match[3]} ${match[4]} is a ${weekday}: ${match[0]}`
159
+ });
160
+ return null;
161
+ }
162
+
140
163
  return [
141
164
  String(year).padStart(4, '0'),
142
165
  String(month).padStart(2, '0'),
@@ -70,7 +70,11 @@ export async function previewLogworkBatch({
70
70
  defaultWorklogTask: normalizedDefaultTask
71
71
  }
72
72
  : enriched;
73
- return typeOfWork ? { ...output, typeOfWork } : output;
73
+ // typeOfWork also changes what gets written, so it is folded into the
74
+ // batchId the same way.
75
+ return typeOfWork
76
+ ? { ...output, typeOfWork, batchId: `${output.batchId}_${hash(`typeOfWork:${typeOfWork}`)}`, summary: `${output.summary}\n\nType of work for every entry: ${typeOfWork}` }
77
+ : output;
74
78
  }
75
79
 
76
80
  export const LOGWORK_LINE_FORMAT = '+<hours> <worklog task> | <description>';
@@ -394,6 +398,16 @@ export async function applyLogworkBatch({
394
398
  ok: false,
395
399
  error: error.message
396
400
  });
401
+ // Earlier entries are already in RO. Say so plainly: re-applying the same
402
+ // text would log them twice.
403
+ const written = results.filter((result) => result.ok);
404
+ const notTried = preview.entries.slice(results.length).map((item) => item.id);
405
+ error.message = `Entry ${entry.id} (+${entry.hours}h ${entry.taskName}) failed: ${error.message}` +
406
+ (written.length
407
+ ? ` Already written to RO: ${written.map((result) => `${result.entryId} (+${result.hours}h ${result.taskName})`).join(', ')}. Do not apply this batch again; preview only the entries that were not written.`
408
+ : ' Nothing was written.') +
409
+ (notTried.length ? ` Not attempted: ${notTried.join(', ')}.` : '');
410
+ error.partialResults = results;
397
411
  error.applyLedger = await recordRoApplyLedger({
398
412
  writer: ledgerWriter,
399
413
  preview,
@@ -1,5 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { homedir } from 'node:os';
2
3
  import { resolve } from 'node:path';
4
+ import { isAuthRequiredError } from './auth-errors.mjs';
3
5
  import { CONFIG } from './config.mjs';
4
6
  import { normalizeQueryRange, queryLogwork } from './query-workflow.mjs';
5
7
  import { addLocalDays, todayLocalDateISO } from './util.mjs';
@@ -15,6 +17,10 @@ import { addLocalDays, todayLocalDateISO } from './util.mjs';
15
17
  // approves it, then log_work previews and applies it as usual.
16
18
 
17
19
  const TICKET_RE = /\b[A-Z][A-Z0-9]+-\d+\b/g;
20
+ // Prefixes that look like ticket keys but are standards or versions
21
+ // ("UTF-8", "ISO-8601", "SHA-256", "CVE-2024-1234").
22
+ const NOT_TICKET_PREFIXES = new Set(['UTF', 'ISO', 'SHA', 'RFC', 'HTTP', 'HTTPS', 'ES', 'ECMA', 'TLS', 'SSL', 'MD', 'AES', 'RSA', 'UTC', 'GMT', 'CVE', 'CWE', 'IPV', 'WCAG', 'PEP', 'JSR', 'ISBN', 'COVID', 'MP', 'H', 'X', 'IE', 'WIN', 'NODE', 'PHP', 'IOS']);
23
+ const MAX_TICKETS_IN_DESCRIPTION = 4;
18
24
  const CONVENTIONAL_RE = /^(\w+)(?:\([^)]*\))?!?:\s*/;
19
25
  const FIELD = '\x1f';
20
26
  const RECORD = '\x1e';
@@ -111,36 +117,77 @@ export function parseGitLog(output = '') {
111
117
 
112
118
  export async function readRepoCommits({ repo, from, to, author, runGit = runGitCommand }) {
113
119
  const root = (await runGit(repo, ['rev-parse', '--show-toplevel'])).trim();
114
- const authorFilter = author || (await runGit(root, ['config', 'user.email']).catch(() => '')).trim();
120
+ const authorFilter = (author || (await runGit(root, ['config', 'user.email']).catch(() => ''))).trim();
115
121
  if (!authorFilter) {
116
122
  throw new Error(`No git author for ${root}. Set git config user.email or pass author.`);
117
123
  }
118
- const output = await runGit(root, [
124
+ // The period is matched on the author date in JS below. Git's --since and
125
+ // --until use the committer date (so rebased or amended commits would fall
126
+ // out of their real day) and --since stops the walk at the first older
127
+ // commit (clock skew). --since-as-filter (git 2.37+) only filters, and a day
128
+ // of slack covers time zones; older git falls back to a 31-day window.
129
+ const sinceDate = addLocalDays(from, -1);
130
+ const baseArgs = [
119
131
  'log',
120
- // Every local and remote branch (work often sits on feature branches), but
121
- // not refs/stash, whose "WIP on"/"index on" commits are not work items.
132
+ // Every local and remote branch (work often sits on feature branches) and
133
+ // HEAD (a detached HEAD mid-rebase), but not refs/stash, whose "WIP on" /
134
+ // "index on" commits are not work items.
122
135
  '--branches',
123
136
  '--remotes',
137
+ 'HEAD',
124
138
  '--no-merges',
125
- `--since=${from}T00:00:00`,
126
- `--until=${to}T00:00:00`,
139
+ // Narrow by author in git (fixed string, so "." or "+" in an email is not
140
+ // a regex); the exact match happens in JS.
127
141
  `--author=${authorFilter}`,
142
+ '--fixed-strings',
128
143
  '--regexp-ignore-case',
129
144
  `--format=${RECORD}%H${FIELD}%at${FIELD}%an${FIELD}%ae${FIELD}%s${FIELD}%b${FIELD}`,
130
145
  '--numstat'
131
- ]);
146
+ ];
147
+ let output;
148
+ try {
149
+ output = await runGit(root, [...baseArgs, `--since-as-filter=${sinceDate}T00:00:00`]);
150
+ } catch (error) {
151
+ if (!/since-as-filter|unrecognized argument|unknown option/i.test(error.message)) {
152
+ throw error;
153
+ }
154
+ output = await runGit(root, [...baseArgs, `--since=${addLocalDays(from, -31)}T00:00:00`]);
155
+ }
132
156
  const seen = new Set();
133
157
  const commits = parseGitLog(output)
158
+ .filter((commit) => matchesAuthor(commit, authorFilter))
134
159
  .filter((commit) => !seen.has(commit.hash) && seen.add(commit.hash))
135
160
  .map((commit) => ({ ...commit, repo: root, date: todayLocalDateISO(new Date(commit.timestamp * 1000)) }))
136
161
  .filter((commit) => commit.date >= from && commit.date < to);
137
162
  return { root, author: authorFilter, commits };
138
163
  }
139
164
 
165
+ // Exact author match: an email filter must equal the author email, a name
166
+ // filter must equal the author name (both case-insensitive). git --author is
167
+ // a substring match, so "me@x.com" alone would also match "jime@x.com".
168
+ export function matchesAuthor(commit, authorFilter) {
169
+ const filter = String(authorFilter || '').trim().toLowerCase();
170
+ if (!filter) {
171
+ return true;
172
+ }
173
+ const email = String(commit.authorEmail || '').toLowerCase();
174
+ const name = String(commit.authorName || '').toLowerCase();
175
+ const bracketed = filter.match(/<([^>]+)>/);
176
+ if (bracketed) {
177
+ return email === bracketed[1].trim();
178
+ }
179
+ return filter.includes('@') ? email === filter : name === filter;
180
+ }
181
+
182
+ export function extractTickets(text = '') {
183
+ return [...new Set((String(text).match(TICKET_RE) || [])
184
+ .filter((key) => !NOT_TICKET_PREFIXES.has(key.slice(0, key.lastIndexOf('-')))))];
185
+ }
186
+
140
187
  export function groupCommits(commits = []) {
141
188
  const groups = new Map();
142
189
  for (const commit of [...commits].sort((a, b) => a.timestamp - b.timestamp)) {
143
- const tickets = [...new Set(`${commit.subject}\n${commit.body}`.match(TICKET_RE) || [])];
190
+ const tickets = extractTickets(`${commit.subject}\n${commit.body}`);
144
191
  const kind = classifyCommit(commit.subject, commit.body);
145
192
  const key = [commit.repo, tickets[0] || '', kind].join('|');
146
193
  const group = groups.get(key) || {
@@ -172,9 +219,14 @@ export function groupCommits(commits = []) {
172
219
  }
173
220
 
174
221
  export function buildDescription(group) {
175
- const ticketSuffix = group.tickets.length ? ` (${group.tickets.join(', ')})` : '';
176
- const room = MAX_DESCRIPTION_LENGTH - ticketSuffix.length;
177
- const subjects = group.subjects.map((subject) => subject.replace(TICKET_RE, '').replace(/\s*[[(]\s*[)\]]\s*/g, ' ').replace(/\s+/g, ' ').trim()).filter(Boolean);
222
+ const shown = group.tickets.slice(0, MAX_TICKETS_IN_DESCRIPTION);
223
+ const more = group.tickets.length - shown.length;
224
+ const ticketSuffix = shown.length ? ` (${shown.join(', ')}${more > 0 ? ` +${more} more` : ''})` : '';
225
+ const room = Math.max(40, MAX_DESCRIPTION_LENGTH - ticketSuffix.length);
226
+ const ticketSet = new Set(group.tickets);
227
+ const subjects = group.subjects
228
+ .map((subject) => subject.replace(TICKET_RE, (key) => (ticketSet.has(key) ? '' : key)).replace(/\s*[[(]\s*[)\]]\s*/g, ' ').replace(/\s+/g, ' ').trim())
229
+ .filter(Boolean);
178
230
  let text = '';
179
231
  for (const subject of subjects) {
180
232
  const next = text ? `${text}; ${subject}` : subject;
@@ -244,7 +296,15 @@ export function remainingHoursByDay(roResult) {
244
296
  byDay.set(day.date, {
245
297
  bookedHours: Number(day.bookedHours || 0),
246
298
  loggedHours: Number(day.loggedHours || 0),
247
- remainingHours: Number(remaining.toFixed(2))
299
+ remainingHours: Number(remaining.toFixed(2)),
300
+ // The draft does not know which project a commit belongs to; this lets
301
+ // the agent move lines to the projects that still have room.
302
+ remainingByProject: projects
303
+ .map((project) => ({
304
+ projectName: project.projectName,
305
+ remainingHours: Number(Math.max(0, Number(project.bookedHours || 0) - Number(project.loggedHours || 0)).toFixed(2))
306
+ }))
307
+ .filter((project) => project.remainingHours > 0)
248
308
  });
249
309
  }
250
310
  return byDay;
@@ -262,15 +322,24 @@ export async function draftLogworkFromCommits({
262
322
  queryRo = queryLogwork
263
323
  } = {}) {
264
324
  const range = from || to ? normalizeQueryRange({ from, to }) : normalizeQueryRange({ period });
265
- const repoPaths = [...new Set((repos?.length ? repos : [cwd]).map((repo) => resolve(cwd, String(repo))))];
325
+ const repoPaths = [...new Set((repos?.length ? repos : [cwd]).map((repo) => resolve(cwd, expandHome(String(repo)))))];
266
326
 
267
327
  const repoResults = [];
268
328
  const commits = [];
329
+ const seenRoots = new Set();
330
+ // A commit counts once even when two paths reach it (a subfolder of the same
331
+ // repo, or a git worktree of it).
332
+ const seenHashes = new Set();
269
333
  for (const repo of repoPaths) {
270
334
  try {
271
335
  const result = await readRepoCommits({ repo, from: range.from, to: range.to, author, runGit });
272
- repoResults.push({ path: result.root, author: result.author, commitCount: result.commits.length });
273
- commits.push(...result.commits);
336
+ if (seenRoots.has(result.root)) {
337
+ continue;
338
+ }
339
+ seenRoots.add(result.root);
340
+ const fresh = result.commits.filter((commit) => !seenHashes.has(commit.hash) && seenHashes.add(commit.hash));
341
+ repoResults.push({ path: result.root, author: result.author, commitCount: fresh.length });
342
+ commits.push(...fresh);
274
343
  } catch (error) {
275
344
  repoResults.push({ path: repo, error: error.message, commitCount: 0 });
276
345
  }
@@ -282,6 +351,11 @@ export async function draftLogworkFromCommits({
282
351
  try {
283
352
  bookings = remainingHoursByDay(await queryRo({ from: range.from, to: range.to, includeEntries: false, cwd }));
284
353
  } catch (error) {
354
+ // A missing or expired RO login must reach the agent as authRequired;
355
+ // falling back to an 8h day would hide hours that are already logged.
356
+ if (isAuthRequiredError(error)) {
357
+ throw error;
358
+ }
285
359
  bookingsError = error.message;
286
360
  }
287
361
  }
@@ -312,7 +386,7 @@ export async function draftLogworkFromCommits({
312
386
  date,
313
387
  targetHours,
314
388
  hoursSource,
315
- ...(booking ? { bookedHours: booking.bookedHours, loggedHours: booking.loggedHours } : {}),
389
+ ...(booking ? { bookedHours: booking.bookedHours, loggedHours: booking.loggedHours, remainingByProject: booking.remainingByProject } : {}),
316
390
  groups: groups.map((group, index) => ({
317
391
  hours: hours[index],
318
392
  task: group.task,
@@ -337,7 +411,7 @@ export async function draftLogworkFromCommits({
337
411
  .join('\n\n');
338
412
 
339
413
  const result = {
340
- status: commits.length ? 'draft' : 'no_commits',
414
+ status: !commits.length ? 'no_commits' : (draftText ? 'draft' : 'no_hours'),
341
415
  range,
342
416
  repos: repoResults,
343
417
  bookings: useBookings ? (bookings ? 'ro' : 'unavailable') : 'skipped',
@@ -371,6 +445,9 @@ export function formatCommitDraftSummary(result) {
371
445
  standard_day: `standard day ${day.targetHours}h`
372
446
  }[day.hoursSource];
373
447
  lines.push('', `${formatDayHeading(day.date)} - ${source}`);
448
+ if (day.remainingByProject?.length) {
449
+ lines.push(` Room by project: ${day.remainingByProject.map((project) => `${project.projectName} ${project.remainingHours}h`).join(', ')}`);
450
+ }
374
451
  for (const group of day.groups) {
375
452
  lines.push(` +${group.hours} ${group.task} | ${group.description} [${group.repo}, ${group.commitCount} commit(s)]`);
376
453
  }
@@ -381,6 +458,10 @@ export function formatCommitDraftSummary(result) {
381
458
  return lines.join('\n');
382
459
  }
383
460
 
461
+ function expandHome(path) {
462
+ return path === '~' || /^~[\\/]/.test(path) ? `${homedir()}${path.slice(1)}` : path;
463
+ }
464
+
384
465
  function runGitCommand(cwd, args) {
385
466
  return new Promise((resolveValue, reject) => {
386
467
  const child = spawn('git', ['-C', cwd, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
package/lib/keychain.mjs CHANGED
@@ -41,16 +41,9 @@ export function createKeychainValueStorage({
41
41
  },
42
42
 
43
43
  async set(token) {
44
- await run([
45
- 'add-generic-password',
46
- '-U',
47
- '-s',
48
- service,
49
- '-a',
50
- account,
51
- '-w',
52
- token
53
- ]);
44
+ // The value goes in on stdin (security's interactive mode, as hex), never
45
+ // in argv where `ps` could show it.
46
+ await run(['-i'], { input: formatAddPasswordCommand({ service, account, value: token }) });
54
47
  },
55
48
 
56
49
  async delete() {
@@ -67,11 +60,25 @@ export function createKeychainValueStorage({
67
60
  };
68
61
  }
69
62
 
70
- function runSecurity(args) {
63
+ export function formatAddPasswordCommand({ service, account, value }) {
64
+ for (const [label, text] of [['service', service], ['account', account]]) {
65
+ if (!/^[A-Za-z0-9._@+-]+$/.test(String(text))) {
66
+ throw new Error(`Unsupported Keychain ${label} name: ${text}`);
67
+ }
68
+ }
69
+ const hex = Buffer.from(String(value), 'utf8').toString('hex');
70
+ return `add-generic-password -U -s "${service}" -a "${account}" -X ${hex}\n`;
71
+ }
72
+
73
+ function runSecurity(args, { input } = {}) {
71
74
  return new Promise((resolve, reject) => {
72
75
  const child = spawn('/usr/bin/security', args, {
73
- stdio: ['ignore', 'pipe', 'pipe']
76
+ stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe']
74
77
  });
78
+ if (input !== undefined) {
79
+ child.stdin.on('error', () => {});
80
+ child.stdin.end(input);
81
+ }
75
82
 
76
83
  let stdout = '';
77
84
  let stderr = '';
@@ -162,6 +162,10 @@ server.registerTool('log_work', {
162
162
  prunePreviewCache(previews);
163
163
  if (confirm === true) {
164
164
  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.
167
+ const pending = resolveApprovedBatch({ cache: previews, batchId });
168
+ assertUnbookedAllowed(pending, allowUnbooked);
165
169
  const approvedBatch = consumeApprovedBatch({ cache: previews, batchId });
166
170
  return formatToolResponse(await applyLogworkBatch({ batch: approvedBatch, confirm, allowUnbooked }));
167
171
  }
@@ -203,7 +207,7 @@ server.registerTool('resubmit_entry', {
203
207
  annotations: WRITE,
204
208
  inputSchema: {
205
209
  logworkId: LOGWORK_ID_SCHEMA,
206
- hours: z.number().positive().optional().describe('Preview call only. New hours; defaults to the rejected entry hours.'),
210
+ hours: z.number().positive().max(24).optional().describe('Preview call only. New hours; defaults to the rejected entry hours.'),
207
211
  typeOfWork: TYPE_OF_WORK_SCHEMA.optional().describe('Preview call only. New type of work; defaults to the rejected entry value.'),
208
212
  noteToPm: z.string().max(500).optional().describe('Preview call only. Note to the project manager explaining the fix.'),
209
213
  description: z.string().max(1_000).optional().describe('Preview call only. New description; defaults to the rejected entry description.'),
@@ -536,7 +540,7 @@ export function resolveApprovedBatch({
536
540
 
537
541
  const cached = getCachedPreviewRecord(cache, batchId, now);
538
542
  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.');
543
+ 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
544
  }
541
545
 
542
546
  if (batch && createPreviewFingerprint(batch) !== cached.fingerprint) {
@@ -546,6 +550,13 @@ export function resolveApprovedBatch({
546
550
  return clonePreview(cached.preview);
547
551
  }
548
552
 
553
+ function assertUnbookedAllowed(batch, allowUnbooked) {
554
+ const unbooked = (batch?.entries || []).filter((entry) => entry.requiresAllowUnbooked);
555
+ if (unbooked.length && allowUnbooked !== true) {
556
+ 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.`);
557
+ }
558
+ }
559
+
549
560
  export function consumeApprovedBatch({
550
561
  cache,
551
562
  batchId,
@@ -578,7 +589,7 @@ export function resolveApprovedJiraBatch({
578
589
 
579
590
  const cached = getCachedPreviewRecord(cache, batchId, now);
580
591
  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.');
592
+ 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
593
  }
583
594
 
584
595
  if (batch && createJiraPreviewFingerprint(batch) !== cached.fingerprint) {
@@ -620,7 +631,7 @@ export function resolveApprovedRoEditPreview({
620
631
 
621
632
  const cached = getCachedPreviewRecord(cache, previewId, now);
622
633
  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.`);
634
+ 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
635
  }
625
636
  if (preview && createRoEditPreviewFingerprint(preview) !== cached.fingerprint) {
626
637
  throw new Error(`Approved ${toolName} preview changed after preview. Preview again before applying.`);
@@ -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.1.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",
@@ -14,7 +14,8 @@ Goal: every logged line is backed by real work (the user's commits or what the u
14
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
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
16
  5. **Set the hours.**
17
- - Stay within the remaining booked hours per day and project unless the user says otherwise.
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.
18
19
  - Use 0.5h steps.
19
20
  - Do not log weekends or days without a booking unless the user asks.
20
21
  - Work without commits (meetings, reviews, support) only comes from the user; ask, then log it as `Other Activities` or `Code Review & Refactoring`.