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,483 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { homedir } from 'node:os';
3
+ import { resolve } from 'node:path';
4
+ import { isAuthRequiredError } from './auth-errors.mjs';
5
+ import { CONFIG } from './config.mjs';
6
+ import { normalizeQueryRange, queryLogwork } from './query-workflow.mjs';
7
+ import { addLocalDays, todayLocalDateISO } from './util.mjs';
8
+
9
+ // Builds a logwork draft from the user's own git commits:
10
+ // commits (all branches, this author, in the period)
11
+ // -> grouped per day by repo + ticket + kind of work
12
+ // -> each group gets a default RO worklog task and a description
13
+ // -> the day's hours (RO booked - logged, or a standard day) are split across
14
+ // groups by how much work each group holds
15
+ // -> a "+<hours> <worklog task> | <description>" block for log_work.
16
+ // Nothing is written anywhere; the agent shows the draft, the user edits and
17
+ // approves it, then log_work previews and applies it as usual.
18
+
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;
24
+ const CONVENTIONAL_RE = /^(\w+)(?:\([^)]*\))?!?:\s*/;
25
+ const FIELD = '\x1f';
26
+ const RECORD = '\x1e';
27
+ const MAX_DESCRIPTION_LENGTH = 240;
28
+
29
+ // Default RO catalog task per kind of work. These are the shared default task
30
+ // names; a project with its own catalog may need a different name (list_tasks).
31
+ export const WORK_KINDS = Object.freeze({
32
+ feature: { task: 'Feature Development', label: 'feature' },
33
+ fix: { task: 'Bug Fix', label: 'fix' },
34
+ refactor: { task: 'Code Review & Refactoring', label: 'refactor/review' },
35
+ test: { task: 'Testing Activities', label: 'test' },
36
+ release: { task: 'Deployment & Release', label: 'build/deploy' },
37
+ design: { task: 'Requirements & Design', label: 'docs/design' },
38
+ other: { task: 'Other Activities', label: 'other' }
39
+ });
40
+
41
+ const CONVENTIONAL_KINDS = new Map([
42
+ ['feat', 'feature'], ['feature', 'feature'],
43
+ ['fix', 'fix'], ['bugfix', 'fix'], ['hotfix', 'fix'], ['revert', 'fix'],
44
+ ['refactor', 'refactor'], ['perf', 'refactor'], ['style', 'refactor'], ['review', 'refactor'],
45
+ ['test', 'test'], ['tests', 'test'], ['e2e', 'test'],
46
+ ['ci', 'release'], ['build', 'release'], ['release', 'release'], ['deploy', 'release'],
47
+ ['docs', 'design'], ['doc', 'design'], ['design', 'design'], ['spec', 'design'],
48
+ ['chore', 'other']
49
+ ]);
50
+
51
+ // Keyword fallback for non-conventional subjects, strongest signal first.
52
+ const KEYWORD_KINDS = [
53
+ [/\b(fix|fixes|fixed|bug|hotfix|crash|regression)\b/i, 'fix'],
54
+ [/\b(tests?|e2e|unit test|coverage|qa)\b/i, 'test'],
55
+ [/\b(deploy|deployment|release|bump|pipeline|docker|helm|k8s|kubernetes)\b/i, 'release'],
56
+ [/\b(refactor|cleanup|clean up|rename|review|lint|format)\b/i, 'refactor'],
57
+ [/\b(docs?|readme|design|spec|requirements?|diagram)\b/i, 'design']
58
+ ];
59
+
60
+ export function classifyCommit(subject = '', body = '') {
61
+ const conventional = CONVENTIONAL_RE.exec(subject);
62
+ if (conventional) {
63
+ const kind = CONVENTIONAL_KINDS.get(conventional[1].toLowerCase());
64
+ if (kind) {
65
+ return kind;
66
+ }
67
+ }
68
+ for (const text of [subject, body]) {
69
+ for (const [pattern, kind] of KEYWORD_KINDS) {
70
+ if (pattern.test(text)) {
71
+ return kind;
72
+ }
73
+ }
74
+ }
75
+ return 'feature';
76
+ }
77
+
78
+ export function cleanSubject(subject = '') {
79
+ return String(subject).replace(CONVENTIONAL_RE, '').trim();
80
+ }
81
+
82
+ export function parseGitLog(output = '') {
83
+ const commits = [];
84
+ for (const chunk of String(output).split(RECORD)) {
85
+ if (!chunk.trim()) {
86
+ continue;
87
+ }
88
+ const [hash, timestamp, authorName, authorEmail, subject, body = '', stats = ''] = chunk.split(FIELD);
89
+ if (!hash || !timestamp) {
90
+ continue;
91
+ }
92
+ let additions = 0;
93
+ let deletions = 0;
94
+ let files = 0;
95
+ for (const line of stats.split(/\r?\n/)) {
96
+ const match = /^(\d+|-)\t(\d+|-)\t/.exec(line.trim());
97
+ if (match) {
98
+ files += 1;
99
+ additions += match[1] === '-' ? 0 : Number(match[1]);
100
+ deletions += match[2] === '-' ? 0 : Number(match[2]);
101
+ }
102
+ }
103
+ commits.push({
104
+ hash: hash.trim(),
105
+ timestamp: Number(timestamp),
106
+ authorName: authorName.trim(),
107
+ authorEmail: authorEmail.trim(),
108
+ subject: subject.trim(),
109
+ body: body.trim(),
110
+ files,
111
+ additions,
112
+ deletions
113
+ });
114
+ }
115
+ return commits;
116
+ }
117
+
118
+ export async function readRepoCommits({ repo, from, to, author, runGit = runGitCommand }) {
119
+ const root = (await runGit(repo, ['rev-parse', '--show-toplevel'])).trim();
120
+ const authorFilter = (author || (await runGit(root, ['config', 'user.email']).catch(() => ''))).trim();
121
+ if (!authorFilter) {
122
+ throw new Error(`No git author for ${root}. Set git config user.email or pass author.`);
123
+ }
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 = [
131
+ 'log',
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.
135
+ '--branches',
136
+ '--remotes',
137
+ 'HEAD',
138
+ '--no-merges',
139
+ // Narrow by author in git (fixed string, so "." or "+" in an email is not
140
+ // a regex); the exact match happens in JS.
141
+ `--author=${authorFilter}`,
142
+ '--fixed-strings',
143
+ '--regexp-ignore-case',
144
+ `--format=${RECORD}%H${FIELD}%at${FIELD}%an${FIELD}%ae${FIELD}%s${FIELD}%b${FIELD}`,
145
+ '--numstat'
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
+ }
156
+ const seen = new Set();
157
+ const commits = parseGitLog(output)
158
+ .filter((commit) => matchesAuthor(commit, authorFilter))
159
+ .filter((commit) => !seen.has(commit.hash) && seen.add(commit.hash))
160
+ .map((commit) => ({ ...commit, repo: root, date: todayLocalDateISO(new Date(commit.timestamp * 1000)) }))
161
+ .filter((commit) => commit.date >= from && commit.date < to);
162
+ return { root, author: authorFilter, commits };
163
+ }
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
+
187
+ export function groupCommits(commits = []) {
188
+ const groups = new Map();
189
+ for (const commit of [...commits].sort((a, b) => a.timestamp - b.timestamp)) {
190
+ const tickets = extractTickets(`${commit.subject}\n${commit.body}`);
191
+ const kind = classifyCommit(commit.subject, commit.body);
192
+ const key = [commit.repo, tickets[0] || '', kind].join('|');
193
+ const group = groups.get(key) || {
194
+ repo: commit.repo,
195
+ repoName: commit.repo.split(/[\\/]/).filter(Boolean).pop() || commit.repo,
196
+ tickets: [],
197
+ kind,
198
+ task: WORK_KINDS[kind].task,
199
+ subjects: [],
200
+ commitCount: 0,
201
+ linesChanged: 0,
202
+ hashes: []
203
+ };
204
+ group.tickets = [...new Set([...group.tickets, ...tickets])];
205
+ const subject = cleanSubject(commit.subject);
206
+ if (subject && !group.subjects.includes(subject)) {
207
+ group.subjects.push(subject);
208
+ }
209
+ group.commitCount += 1;
210
+ group.linesChanged += commit.additions + commit.deletions;
211
+ group.hashes.push(commit.hash.slice(0, 8));
212
+ groups.set(key, group);
213
+ }
214
+ return [...groups.values()].map((group) => ({
215
+ ...group,
216
+ description: buildDescription(group),
217
+ weight: group.commitCount + Math.min(3, group.linesChanged / 200)
218
+ }));
219
+ }
220
+
221
+ export function buildDescription(group) {
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);
230
+ let text = '';
231
+ for (const subject of subjects) {
232
+ const next = text ? `${text}; ${subject}` : subject;
233
+ if (next.length > room) {
234
+ text = text || subject.slice(0, Math.max(0, room - 1)).trim();
235
+ break;
236
+ }
237
+ text = next;
238
+ }
239
+ return `${text || group.repoName}${ticketSuffix}`.replace(/\|/g, '/');
240
+ }
241
+
242
+ // Splits targetHours across groups by weight in hourStep increments. Every
243
+ // group that gets time gets at least one step; when the day has fewer steps than
244
+ // groups, the heaviest groups win and the rest get 0 (still listed).
245
+ export function allocateHours(groups, targetHours, step = CONFIG.hourStep || 0.5) {
246
+ const totalSteps = Math.max(0, Math.floor((Number(targetHours) || 0) / step + 1e-9));
247
+ const ordered = groups.map((group, index) => ({ index, weight: group.weight || 1 }))
248
+ .sort((a, b) => b.weight - a.weight);
249
+ const steps = new Array(groups.length).fill(0);
250
+ if (!totalSteps || !groups.length) {
251
+ return steps.map(() => 0);
252
+ }
253
+ const funded = ordered.slice(0, Math.min(totalSteps, ordered.length));
254
+ for (const item of funded) {
255
+ steps[item.index] = 1;
256
+ }
257
+ let remaining = totalSteps - funded.length;
258
+ const totalWeight = funded.reduce((sum, item) => sum + item.weight, 0);
259
+ const shares = funded.map((item) => ({ ...item, exact: (item.weight / totalWeight) * remaining }));
260
+ for (const share of shares) {
261
+ const whole = Math.floor(share.exact);
262
+ steps[share.index] += whole;
263
+ remaining -= whole;
264
+ }
265
+ shares.sort((a, b) => (b.exact - Math.floor(b.exact)) - (a.exact - Math.floor(a.exact)));
266
+ for (let i = 0; remaining > 0; i = (i + 1) % shares.length) {
267
+ steps[shares[i].index] += 1;
268
+ remaining -= 1;
269
+ }
270
+ return steps.map((count) => Number((count * step).toFixed(2)));
271
+ }
272
+
273
+ export function formatDayHeading(localDateISO) {
274
+ const [year, month, day] = localDateISO.split('-').map(Number);
275
+ const date = new Date(Date.UTC(year, month - 1, day));
276
+ const weekday = date.toLocaleDateString('en-US', { weekday: 'long', timeZone: 'UTC' });
277
+ const monthName = date.toLocaleDateString('en-US', { month: 'short', timeZone: 'UTC' });
278
+ return `${weekday}, ${String(day).padStart(2, '0')} ${monthName} ${year}`;
279
+ }
280
+
281
+ function isWeekend(localDateISO) {
282
+ const [year, month, day] = localDateISO.split('-').map(Number);
283
+ const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
284
+ return weekday === 0 || weekday === 6;
285
+ }
286
+
287
+ // Remaining RO time per day: sum over projects of max(0, booked - logged), so
288
+ // over-logging one project does not hide a gap on another.
289
+ export function remainingHoursByDay(roResult) {
290
+ const byDay = new Map();
291
+ for (const day of roResult?.days || []) {
292
+ const projects = day.projects || [];
293
+ const remaining = projects.length
294
+ ? projects.reduce((sum, project) => sum + Math.max(0, Number(project.bookedHours || 0) - Number(project.loggedHours || 0)), 0)
295
+ : Math.max(0, Number(day.bookedHours || 0) - Number(day.loggedHours || 0));
296
+ byDay.set(day.date, {
297
+ bookedHours: Number(day.bookedHours || 0),
298
+ loggedHours: Number(day.loggedHours || 0),
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)
308
+ });
309
+ }
310
+ return byDay;
311
+ }
312
+
313
+ export async function draftLogworkFromCommits({
314
+ period = 'today',
315
+ from,
316
+ to,
317
+ repos,
318
+ author,
319
+ useBookings = true,
320
+ cwd = process.cwd(),
321
+ runGit = runGitCommand,
322
+ queryRo = queryLogwork
323
+ } = {}) {
324
+ const range = from || to ? normalizeQueryRange({ from, to }) : normalizeQueryRange({ period });
325
+ const repoPaths = [...new Set((repos?.length ? repos : [cwd]).map((repo) => resolve(cwd, expandHome(String(repo)))))];
326
+
327
+ const repoResults = [];
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();
333
+ for (const repo of repoPaths) {
334
+ try {
335
+ const result = await readRepoCommits({ repo, from: range.from, to: range.to, author, runGit });
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);
343
+ } catch (error) {
344
+ repoResults.push({ path: repo, error: error.message, commitCount: 0 });
345
+ }
346
+ }
347
+
348
+ let bookings = null;
349
+ let bookingsError = '';
350
+ if (useBookings) {
351
+ try {
352
+ bookings = remainingHoursByDay(await queryRo({ from: range.from, to: range.to, includeEntries: false, cwd }));
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
+ }
359
+ bookingsError = error.message;
360
+ }
361
+ }
362
+
363
+ const days = [];
364
+ for (let date = range.from; date < range.to; date = addLocalDays(date, 1)) {
365
+ const dayCommits = commits.filter((commit) => commit.date === date);
366
+ if (!dayCommits.length) {
367
+ continue;
368
+ }
369
+ const groups = groupCommits(dayCommits);
370
+ const booking = bookings?.get(date);
371
+ let targetHours;
372
+ let hoursSource;
373
+ if (booking) {
374
+ targetHours = booking.remainingHours;
375
+ hoursSource = booking.bookedHours ? 'ro_remaining' : 'ro_no_booking';
376
+ } else if (bookings) {
377
+ // RO answered but has no row for this day: nothing is booked.
378
+ targetHours = 0;
379
+ hoursSource = 'ro_no_booking';
380
+ } else {
381
+ targetHours = isWeekend(date) ? 0 : (CONFIG.standardDayHours || 8);
382
+ hoursSource = isWeekend(date) ? 'weekend' : 'standard_day';
383
+ }
384
+ const hours = allocateHours(groups, targetHours);
385
+ days.push({
386
+ date,
387
+ targetHours,
388
+ hoursSource,
389
+ ...(booking ? { bookedHours: booking.bookedHours, loggedHours: booking.loggedHours, remainingByProject: booking.remainingByProject } : {}),
390
+ groups: groups.map((group, index) => ({
391
+ hours: hours[index],
392
+ task: group.task,
393
+ kind: group.kind,
394
+ description: group.description,
395
+ tickets: group.tickets,
396
+ repo: group.repoName,
397
+ commitCount: group.commitCount,
398
+ linesChanged: group.linesChanged,
399
+ commits: group.hashes
400
+ }))
401
+ });
402
+ }
403
+
404
+ const draftText = days
405
+ .map((day) => {
406
+ const lines = day.groups.filter((group) => group.hours > 0)
407
+ .map((group) => `+${group.hours} ${group.task} | ${group.description}`);
408
+ return lines.length ? [formatDayHeading(day.date), ...lines].join('\n') : '';
409
+ })
410
+ .filter(Boolean)
411
+ .join('\n\n');
412
+
413
+ const result = {
414
+ status: !commits.length ? 'no_commits' : (draftText ? 'draft' : 'no_hours'),
415
+ range,
416
+ repos: repoResults,
417
+ bookings: useBookings ? (bookings ? 'ro' : 'unavailable') : 'skipped',
418
+ ...(bookingsError ? { bookingsError } : {}),
419
+ commitCount: commits.length,
420
+ days,
421
+ draftText
422
+ };
423
+ result.summary = formatCommitDraftSummary(result);
424
+ return result;
425
+ }
426
+
427
+ export function formatCommitDraftSummary(result) {
428
+ const lines = [];
429
+ if (!result.commitCount) {
430
+ lines.push(`No commits found from ${result.range.from} to ${addLocalDays(result.range.to, -1)}.`);
431
+ } else {
432
+ lines.push(`${result.commitCount} commit(s) from ${result.range.from} to ${addLocalDays(result.range.to, -1)}.`);
433
+ }
434
+ for (const repo of result.repos) {
435
+ lines.push(repo.error ? `- ${repo.path}: ${repo.error}` : `- ${repo.path} (${repo.author}): ${repo.commitCount} commit(s)`);
436
+ }
437
+ if (result.bookings === 'unavailable') {
438
+ lines.push(`RO bookings unavailable (${result.bookingsError || 'unknown error'}); hours assume a ${CONFIG.standardDayHours || 8}h weekday.`);
439
+ }
440
+ for (const day of result.days) {
441
+ const source = {
442
+ ro_remaining: `RO remaining ${day.targetHours}h (booked ${day.bookedHours}h, logged ${day.loggedHours}h)`,
443
+ ro_no_booking: 'no RO booking: 0h suggested',
444
+ weekend: 'weekend: 0h suggested',
445
+ standard_day: `standard day ${day.targetHours}h`
446
+ }[day.hoursSource];
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
+ }
451
+ for (const group of day.groups) {
452
+ lines.push(` +${group.hours} ${group.task} | ${group.description} [${group.repo}, ${group.commitCount} commit(s)]`);
453
+ }
454
+ }
455
+ if (result.draftText) {
456
+ lines.push('', 'Draft for log_work (review with the user first; worklog task names must exist in list_tasks for the project):', '', result.draftText);
457
+ }
458
+ return lines.join('\n');
459
+ }
460
+
461
+ function expandHome(path) {
462
+ return path === '~' || /^~[\\/]/.test(path) ? `${homedir()}${path.slice(1)}` : path;
463
+ }
464
+
465
+ function runGitCommand(cwd, args) {
466
+ return new Promise((resolveValue, reject) => {
467
+ const child = spawn('git', ['-C', cwd, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
468
+ let stdout = '';
469
+ let stderr = '';
470
+ child.stdout.setEncoding('utf8');
471
+ child.stderr.setEncoding('utf8');
472
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
473
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
474
+ child.on('error', reject);
475
+ child.on('close', (code) => {
476
+ if (code === 0) {
477
+ resolveValue(stdout);
478
+ return;
479
+ }
480
+ reject(new Error(stderr.trim().split('\n')[0] || `git ${args[0]} failed with exit code ${code}`));
481
+ });
482
+ });
483
+ }
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 = '';
@@ -3,6 +3,8 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
3
  import { z } from 'zod';
4
4
  import { ApiError } from './api.mjs';
5
5
  import { queryApplyLedger } from './apply-ledger.mjs';
6
+ import { readLogworkSkill } from './agent-skill.mjs';
7
+ import { draftLogworkFromCommits } from './commit-workflow.mjs';
6
8
  import {
7
9
  applyLogworkBatch,
8
10
  formatToolResponse,
@@ -73,7 +75,8 @@ Logwork text format (log_work and log_jira_work):
73
75
  - 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).
74
76
  - Jira needs exactly one issue key such as SCB-213 in the line.
75
77
 
76
- Recommended RO flow: get_logwork (what is booked/logged) -> list_projects -> list_tasks(projectId) -> log_work(text) -> user approval -> log_work(batchId, confirm: true).
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).
79
+ 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.
77
80
  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.
78
81
  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.`;
79
82
 
@@ -128,6 +131,20 @@ server.registerTool('list_tasks', {
128
131
  }
129
132
  }, withAuthRequiredHandling(async ({ projectId }) => formatToolResponse(await listWorklogTasks({ projectId }))));
130
133
 
134
+ server.registerTool('draft_from_commits', {
135
+ 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.',
137
+ annotations: READ_ONLY,
138
+ inputSchema: {
139
+ period: z.enum(PERIOD_VALUES).optional().describe('Preset period. Defaults to today.'),
140
+ repos: z.array(z.string().min(1)).max(20).optional().describe('Absolute paths of the git repos the user worked in (for example the open workspace folders). Defaults to the MCP server working directory.'),
141
+ author: z.string().min(1).optional().describe('Git author filter (email or name). Defaults to each repo\'s git config user.email.'),
142
+ useBookings: z.boolean().optional().describe('Split hours by RO remaining booked time per day (default true). When false or RO is unavailable, a weekday counts as 8h.')
143
+ }
144
+ }, withAuthRequiredHandling(async ({ period, repos, author, useBookings = true }) => {
145
+ return formatToolResponse(await draftLogworkFromCommits({ period, repos, author, useBookings }));
146
+ }));
147
+
131
148
  server.registerTool('log_work', {
132
149
  title: 'Log work to Resource Optimiser',
133
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.`,
@@ -145,6 +162,10 @@ server.registerTool('log_work', {
145
162
  prunePreviewCache(previews);
146
163
  if (confirm === true) {
147
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);
148
169
  const approvedBatch = consumeApprovedBatch({ cache: previews, batchId });
149
170
  return formatToolResponse(await applyLogworkBatch({ batch: approvedBatch, confirm, allowUnbooked }));
150
171
  }
@@ -186,7 +207,7 @@ server.registerTool('resubmit_entry', {
186
207
  annotations: WRITE,
187
208
  inputSchema: {
188
209
  logworkId: LOGWORK_ID_SCHEMA,
189
- 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.'),
190
211
  typeOfWork: TYPE_OF_WORK_SCHEMA.optional().describe('Preview call only. New type of work; defaults to the rejected entry value.'),
191
212
  noteToPm: z.string().max(500).optional().describe('Preview call only. Note to the project manager explaining the fix.'),
192
213
  description: z.string().max(1_000).optional().describe('Preview call only. New description; defaults to the rejected entry description.'),
@@ -373,6 +394,29 @@ server.registerTool('update', {
373
394
  return formatToolResponse(await installUpdate({ version, confirm, stdio: 'pipe' }));
374
395
  });
375
396
 
397
+ server.registerPrompt('log_from_commits', {
398
+ title: 'Log work from my commits',
399
+ description: 'Step-by-step procedure for logging accurate Resource Optimiser (and optionally Jira) work from the user\'s git commits.',
400
+ argsSchema: {
401
+ period: z.string().optional().describe('today, yesterday, this_week, last_week, this_month or last_month. Defaults to today.'),
402
+ repos: z.string().optional().describe('Comma-separated absolute repo paths. Defaults to the open workspace.')
403
+ }
404
+ }, ({ period, repos }) => ({
405
+ messages: [{
406
+ role: 'user',
407
+ content: {
408
+ type: 'text',
409
+ text: [
410
+ readLogworkSkill(),
411
+ '',
412
+ `Period: ${period || 'today'}.`,
413
+ repos ? `Repos: ${repos}.` : 'Repos: the folders open in this workspace (ask me if there are others).',
414
+ 'Start with get_logwork, then draft_from_commits.'
415
+ ].join('\n')
416
+ }
417
+ }]
418
+ }));
419
+
376
420
  export async function startMcpServer() {
377
421
  const transport = new StdioServerTransport();
378
422
  await server.connect(transport);
@@ -496,7 +540,7 @@ export function resolveApprovedBatch({
496
540
 
497
541
  const cached = getCachedPreviewRecord(cache, batchId, now);
498
542
  if (!cached) {
499
- 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.');
500
544
  }
501
545
 
502
546
  if (batch && createPreviewFingerprint(batch) !== cached.fingerprint) {
@@ -506,6 +550,13 @@ export function resolveApprovedBatch({
506
550
  return clonePreview(cached.preview);
507
551
  }
508
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
+
509
560
  export function consumeApprovedBatch({
510
561
  cache,
511
562
  batchId,
@@ -538,7 +589,7 @@ export function resolveApprovedJiraBatch({
538
589
 
539
590
  const cached = getCachedPreviewRecord(cache, batchId, now);
540
591
  if (!cached) {
541
- 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.');
542
593
  }
543
594
 
544
595
  if (batch && createJiraPreviewFingerprint(batch) !== cached.fingerprint) {
@@ -580,7 +631,7 @@ export function resolveApprovedRoEditPreview({
580
631
 
581
632
  const cached = getCachedPreviewRecord(cache, previewId, now);
582
633
  if (!cached) {
583
- 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.`);
584
635
  }
585
636
  if (preview && createRoEditPreviewFingerprint(preview) !== cached.fingerprint) {
586
637
  throw new Error(`Approved ${toolName} preview changed after preview. Preview again before applying.`);