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.
- package/README.md +56 -3
- package/RELEASE.md +1 -1
- package/bin/logwork.mjs +3 -0
- package/commands/commits.mjs +82 -0
- package/commands/setup.mjs +111 -3
- package/commands/skill.mjs +91 -0
- package/docs/mcp-setup.md +23 -2
- package/docs/operations-guide.md +2 -0
- package/lib/agent-skill.mjs +16 -0
- package/lib/batch-parser.mjs +23 -0
- package/lib/batch-workflow.mjs +15 -1
- package/lib/commit-workflow.mjs +483 -0
- package/lib/keychain.mjs +19 -12
- package/lib/mcp-server.mjs +56 -5
- package/lib/npm-command.mjs +29 -0
- package/lib/paths.mjs +4 -1
- package/lib/release-doctor.mjs +4 -2
- package/lib/ro-delete-workflow.mjs +3 -7
- package/lib/ro-edit-workflow.mjs +66 -8
- package/lib/ro-entry-reader.mjs +39 -1
- package/lib/ro-resubmit-workflow.mjs +33 -20
- package/lib/update-service.mjs +5 -2
- package/package.json +2 -1
- package/skills/logwork/SKILL.md +44 -0
|
@@ -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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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 = '';
|
package/lib/mcp-server.mjs
CHANGED
|
@@ -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) ->
|
|
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
|
|
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
|
|
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
|
|
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.`);
|