logwork-cli 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -9
- package/RELEASE.md +1 -0
- package/commands/commits.mjs +57 -6
- package/commands/history.mjs +0 -4
- package/commands/quick.mjs +38 -23
- package/commands/reminder.mjs +41 -2
- package/commands/setup.mjs +157 -19
- package/commands/tui.mjs +1 -0
- package/docs/advanced.md +12 -2
- package/docs/mcp-setup.md +57 -15
- package/docs/operations-guide.md +37 -2
- package/docs/recipes.md +27 -0
- package/docs/security.md +3 -1
- package/examples/mcp/antigravity.mcp_config.json +1 -1
- package/examples/mcp/codex.config.toml +1 -1
- package/examples/mcp/copilot.mcp.json +1 -1
- package/lib/api-auth-forms.mjs +393 -0
- package/lib/api-auth-http.mjs +184 -0
- package/lib/api-auth-support.mjs +101 -0
- package/lib/api-auth-tokens.mjs +129 -0
- package/lib/api-auth.mjs +39 -813
- package/lib/api.mjs +38 -1422
- package/lib/apply-ledger.mjs +1 -1
- package/lib/batch-parser.mjs +23 -0
- package/lib/batch-workflow.mjs +201 -2
- package/lib/commit-workflow.mjs +278 -30
- package/lib/config.mjs +7 -0
- package/lib/diagnostics.mjs +53 -4
- package/lib/jira-api.mjs +25 -3
- package/lib/jira-workflow.mjs +36 -6
- package/lib/keychain.mjs +19 -12
- package/lib/logwork-config.mjs +64 -13
- package/lib/manual-apply-state.mjs +15 -0
- package/lib/manual-commands.mjs +2 -2
- package/lib/manual-ink-app.mjs +108 -1281
- package/lib/manual-ink-draft-actions.mjs +227 -0
- package/lib/manual-ink-interaction.mjs +306 -0
- package/lib/manual-ink-session.mjs +41 -0
- package/lib/manual-ink-task-editor.mjs +338 -0
- package/lib/manual-ink-wizard.mjs +520 -0
- package/lib/manual-mcp.mjs +27 -15
- package/lib/manual-repl.mjs +39 -3
- package/lib/mcp-server.mjs +63 -21
- package/lib/node-runtime.mjs +115 -0
- package/lib/npm-command.mjs +29 -0
- package/lib/paths.mjs +11 -1
- package/lib/project-mapping-workflow.mjs +30 -7
- package/lib/query-workflow.mjs +0 -1
- package/lib/reconciliation-workflow.mjs +1 -1
- package/lib/release-doctor.mjs +4 -2
- package/lib/reminder-config.mjs +38 -2
- package/lib/reminder-scheduler.mjs +54 -3
- package/lib/reminder-service.mjs +28 -3
- package/lib/reminder-workflow.mjs +118 -22
- package/lib/ro-api-diagnostics.mjs +109 -0
- package/lib/ro-api-entries.mjs +359 -0
- package/lib/ro-api-fields.mjs +290 -0
- package/lib/ro-api-http.mjs +68 -0
- package/lib/ro-api-projects.mjs +137 -0
- package/lib/ro-api-tasks.mjs +50 -0
- package/lib/ro-api-timesheet.mjs +413 -0
- package/lib/ro-delete-workflow.mjs +5 -8
- package/lib/ro-edit-workflow.mjs +66 -8
- package/lib/ro-entry-reader.mjs +49 -6
- package/lib/ro-resubmit-workflow.mjs +35 -21
- package/lib/terminal-auth-prompts.mjs +7 -4
- package/lib/ui.mjs +80 -59
- package/lib/update-service.mjs +5 -2
- package/package.json +11 -4
- package/skills/logwork/SKILL.md +6 -4
package/lib/manual-repl.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { stdin as defaultInput, stdout as defaultOutput } from 'node:process';
|
|
2
|
+
import { resolve as resolvePath } from 'node:path';
|
|
2
3
|
import { confirm, isCancel } from '@clack/prompts';
|
|
3
4
|
import { getStoredAuthStatus, loginResourceOptimiser } from './auth.mjs';
|
|
4
5
|
import { queryApplyLedger } from './apply-ledger.mjs';
|
|
@@ -30,8 +31,10 @@ import {
|
|
|
30
31
|
import { findManualCommand, formatManualHelp } from './manual-commands.mjs';
|
|
31
32
|
import {
|
|
32
33
|
buildManualApplyConfirmation,
|
|
34
|
+
buildManualDuplicateConfirmation,
|
|
33
35
|
buildManualUnbookedConfirmation,
|
|
34
36
|
getManualApplyBlocker,
|
|
37
|
+
manualDuplicateEntryCount,
|
|
35
38
|
manualUnbookedEntryCount
|
|
36
39
|
} from './manual-apply-state.mjs';
|
|
37
40
|
import { runManualInkApp } from './manual-ink-app.mjs';
|
|
@@ -363,7 +366,11 @@ export async function executeManualCommand(command, session, context) {
|
|
|
363
366
|
}
|
|
364
367
|
|
|
365
368
|
if (command.type === 'map') {
|
|
366
|
-
const
|
|
369
|
+
const subject = [
|
|
370
|
+
...command.tickets,
|
|
371
|
+
...(command.repos || []).map((repo) => `repo ${resolvePath(cwd, repo)}`)
|
|
372
|
+
].join(', ');
|
|
373
|
+
const ok = await prompts.confirm(`Map ${subject} to projectMemberId ${command.projectMemberId}?`);
|
|
367
374
|
if (!ok) {
|
|
368
375
|
print('Mapping cancelled.');
|
|
369
376
|
return { exit: false };
|
|
@@ -373,6 +380,7 @@ export async function executeManualCommand(command, session, context) {
|
|
|
373
380
|
projectMemberId: command.projectMemberId,
|
|
374
381
|
tickets: command.tickets,
|
|
375
382
|
keywords: [],
|
|
383
|
+
...(command.repos?.length ? { repos: command.repos } : {}),
|
|
376
384
|
scope: 'user',
|
|
377
385
|
confirm: true,
|
|
378
386
|
cwd
|
|
@@ -666,10 +674,20 @@ async function applyManualRoPreview({
|
|
|
666
674
|
}
|
|
667
675
|
}
|
|
668
676
|
|
|
677
|
+
let allowDuplicates = false;
|
|
678
|
+
if (manualDuplicateEntryCount(preview)) {
|
|
679
|
+
allowDuplicates = await prompts.confirm(buildManualDuplicateConfirmation(preview), false);
|
|
680
|
+
if (!allowDuplicates) {
|
|
681
|
+
print('Resource Optimiser apply cancelled because possible duplicates were not approved. Nothing was written.');
|
|
682
|
+
return false;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
669
686
|
const result = await runWithInlineAuth(() => workflows.applyLogworkBatch({
|
|
670
687
|
batch: preview,
|
|
671
688
|
confirm: true,
|
|
672
689
|
allowUnbooked,
|
|
690
|
+
allowDuplicates,
|
|
673
691
|
cwd,
|
|
674
692
|
ledgerContext: { flowTarget }
|
|
675
693
|
}), { workflows, print });
|
|
@@ -871,9 +889,27 @@ function parseTargetCommand(type, parts, {
|
|
|
871
889
|
};
|
|
872
890
|
}
|
|
873
891
|
|
|
892
|
+
const MAP_USAGE = 'Usage: /map <TICKET_PREFIX[,PREFIX...]> <projectMemberId> | /map --repo [path] <projectMemberId>';
|
|
893
|
+
|
|
874
894
|
function parseMapCommand(parts) {
|
|
895
|
+
if (parts[0] === '--repo') {
|
|
896
|
+
// The repo path defaults to the current folder; a path with spaces may be
|
|
897
|
+
// quoted.
|
|
898
|
+
const projectMemberId = parts.length > 1 ? parts[parts.length - 1] : '';
|
|
899
|
+
const repo = parts.slice(1, -1).join(' ').replace(/^(["'])(.*)\1$/, '$2').trim() || '.';
|
|
900
|
+
if (!projectMemberId || projectMemberId.startsWith('-')) {
|
|
901
|
+
throw new Error(MAP_USAGE);
|
|
902
|
+
}
|
|
903
|
+
return {
|
|
904
|
+
type: 'map',
|
|
905
|
+
tickets: [],
|
|
906
|
+
repos: [repo],
|
|
907
|
+
projectMemberId
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
|
|
875
911
|
if (parts.length < 2) {
|
|
876
|
-
throw new Error(
|
|
912
|
+
throw new Error(MAP_USAGE);
|
|
877
913
|
}
|
|
878
914
|
|
|
879
915
|
const tickets = parts[0]
|
|
@@ -883,7 +919,7 @@ function parseMapCommand(parts) {
|
|
|
883
919
|
const projectMemberId = parts[1];
|
|
884
920
|
|
|
885
921
|
if (!tickets.length || !projectMemberId) {
|
|
886
|
-
throw new Error(
|
|
922
|
+
throw new Error(MAP_USAGE);
|
|
887
923
|
}
|
|
888
924
|
|
|
889
925
|
return {
|
package/lib/mcp-server.mjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import { ApiError } from './api.mjs';
|
|
4
|
+
import { ApiError } from './ro-api-http.mjs';
|
|
5
5
|
import { queryApplyLedger } from './apply-ledger.mjs';
|
|
6
6
|
import { readLogworkSkill } from './agent-skill.mjs';
|
|
7
7
|
import { draftLogworkFromCommits } from './commit-workflow.mjs';
|
|
8
8
|
import {
|
|
9
9
|
applyLogworkBatch,
|
|
10
|
+
formatDuplicateBlock,
|
|
10
11
|
formatToolResponse,
|
|
11
12
|
listWorklogTasks,
|
|
12
13
|
previewLogworkBatch
|
|
@@ -64,6 +65,7 @@ export const SERVER_INSTRUCTIONS = `logwork logs work hours to Resource Optimise
|
|
|
64
65
|
|
|
65
66
|
Safety rules:
|
|
66
67
|
- Writes are two calls to the same tool. Call it without confirm to get a preview (batchId or previewId plus a summary), show the summary to the user, wait for explicit approval, then call the same tool again with only that id and confirm: true. Never set confirm: true without approval.
|
|
68
|
+
- log_work previews flag possibleDuplicate entries (same date, worklog task, hours and description as an existing RO entry, or a repeated line). Show the warning; apply refuses them unless the user confirms it is new work and you call again with the same batchId and allowDuplicates: true. Never pass allowDuplicates or allowUnbooked without asking.
|
|
67
69
|
- Never ask for or pass passwords, OTP codes, or tokens. If a tool returns authRequired, call login (target "ro" or "jira") and ask the user to finish login in their terminal.
|
|
68
70
|
|
|
69
71
|
Logwork text format (log_work and log_jira_work):
|
|
@@ -75,7 +77,7 @@ Logwork text format (log_work and log_jira_work):
|
|
|
75
77
|
- Lines without "|" are accepted only when the text is itself a catalog task name (no description is written), or when defaultWorklogTask is passed (the text then becomes the description).
|
|
76
78
|
- Jira needs exactly one issue key such as SCB-213 in the line.
|
|
77
79
|
|
|
78
|
-
Recommended RO flow: get_logwork (what is booked/logged) -> draft_from_commits (the user's git commits as draft lines) -> list_tasks(projectId) -> log_work(text) -> user approval -> log_work(batchId, confirm: true).
|
|
80
|
+
Recommended RO flow: get_logwork (what is booked/logged) -> draft_from_commits (the user's git commits as draft lines plus projectOverrides) -> list_tasks(projectId) -> log_work(text, projectOverrides) -> user approval -> log_work(batchId, confirm: true).
|
|
79
81
|
Base every line on the user's commits or on what the user tells you; never invent work. The log_from_commits prompt has the full procedure.
|
|
80
82
|
Fixing existing RO entries (logworkId from get_logwork): submitted/approved entries -> edit_entry (hours, worklog task, type of work, description; apply replaces the entry with a new id, approved immediately); rejected entries -> resubmit_entry; removing an entry -> delete_entry.
|
|
81
83
|
If a preview entry is unresolved: worklog_task_required / task_not_found means fix the worklog task (see availableTaskNames); project reasons mean ask the user to pick a project (projectOverrides) or save a mapping with map_project.`;
|
|
@@ -133,7 +135,7 @@ server.registerTool('list_tasks', {
|
|
|
133
135
|
|
|
134
136
|
server.registerTool('draft_from_commits', {
|
|
135
137
|
title: 'Draft logwork from git commits',
|
|
136
|
-
description: 'Read-only: read the user\'s own git commits (all branches, git user.email) in one or more repos for a period, group them per day by repo + ticket + kind of work, suggest an RO worklog task per group (feat -> Feature Development, fix -> Bug Fix, refactor/review -> Code Review & Refactoring, test -> Testing Activities, ci/build/release -> Deployment & Release, docs -> Requirements & Design), and
|
|
138
|
+
description: 'Read-only: read the user\'s own git commits (all branches, git user.email) in one or more repos for a period, group them per day by repo + ticket + kind of work, suggest an RO worklog task per group (feat -> Feature Development, fix -> Bug Fix, refactor/review -> Code Review & Refactoring, test -> Testing Activities, ci/build/release -> Deployment & Release, docs -> Requirements & Design), and resolve each group\'s RO project (a map_project mapping whose repos holds the repo, else ticket/keyword mappings and that day\'s bookings), and split each project\'s remaining RO hours (booked - logged) across its groups; groups without a project share the room of booked projects no group claimed. Returns draftText in the log_work line format plus projectOverrides (preview entry id -> projectMemberId) for every line with a project. Review it with the user, check task names with list_tasks, then pass draftText and projectOverrides unchanged to log_work. unmappedRepos lists repos whose lines got no project: offer map_project with repos.',
|
|
137
139
|
annotations: READ_ONLY,
|
|
138
140
|
inputSchema: {
|
|
139
141
|
period: z.enum(PERIOD_VALUES).optional().describe('Preset period. Defaults to today.'),
|
|
@@ -147,23 +149,29 @@ server.registerTool('draft_from_commits', {
|
|
|
147
149
|
|
|
148
150
|
server.registerTool('log_work', {
|
|
149
151
|
title: 'Log work to Resource Optimiser',
|
|
150
|
-
description: `Resource Optimiser only: create new RO entries (worklog task + hours + date + type of work + description) from a weekly logwork block. ${TWO_STEP} The preview resolves the project (bookings, mappings, ticket prefixes) and worklog task per line; unresolved lines block apply. Use log_jira_work for Jira.`,
|
|
152
|
+
description: `Resource Optimiser only: create new RO entries (worklog task + hours + date + type of work + description) from a weekly logwork block. ${TWO_STEP} The preview resolves the project (bookings, mappings, ticket prefixes) and worklog task per line; unresolved lines block apply. It also reads each date's RO entries and flags possibleDuplicate lines (same date, worklog task, hours and description, or a repeated line); apply refuses them unless allowDuplicates: true. Use log_jira_work for Jira.`,
|
|
151
153
|
annotations: WRITE,
|
|
152
154
|
inputSchema: {
|
|
153
155
|
text: z.string().min(1).optional().describe(ENTRY_TEXT_DESCRIPTION),
|
|
154
|
-
projectOverrides: z.record(z.string(), z.union([z.string(), z.number()])).optional().describe('Preview call only. Map preview entry id (e.g. "2026-06-01-01") to the projectMemberId the user chose for an unresolved entry.'),
|
|
156
|
+
projectOverrides: z.record(z.string(), z.union([z.string(), z.number()])).optional().describe('Preview call only. Map preview entry id (e.g. "2026-06-01-01") to the projectMemberId the user chose for an unresolved entry, or pass the projectOverrides returned by draft_from_commits with its draftText unchanged.'),
|
|
155
157
|
defaultWorklogTask: z.string().min(1).optional().describe('Preview call only. Worklog task name (or "#<id>") for lines without "|" whose text is not a catalog task; that text becomes the description. Example: "Feature Development".'),
|
|
156
158
|
typeOfWork: TYPE_OF_WORK_SCHEMA.optional().describe('Preview call only. Override type_of_work for every entry. When omitted, each entry takes its worklog task type, else "other".'),
|
|
157
159
|
batchId: BATCH_ID_SCHEMA,
|
|
158
160
|
allowUnbooked: z.boolean().optional().describe('Apply call only. Allow entries resolved to a project membership without a booking on that date.'),
|
|
161
|
+
allowDuplicates: z.boolean().optional().describe('Apply call only. Allow entries the preview flagged as possibleDuplicate (same date, worklog task, hours and description as an existing RO entry or an earlier line). Pass true only after the user confirmed they are new work.'),
|
|
159
162
|
confirm: CONFIRM_SCHEMA
|
|
160
163
|
}
|
|
161
|
-
}, withAuthRequiredHandling(async ({ text, projectOverrides, defaultWorklogTask, typeOfWork, batchId, allowUnbooked = false, confirm }) => {
|
|
164
|
+
}, withAuthRequiredHandling(async ({ text, projectOverrides, defaultWorklogTask, typeOfWork, batchId, allowUnbooked = false, allowDuplicates = false, confirm }) => {
|
|
162
165
|
prunePreviewCache(previews);
|
|
163
166
|
if (confirm === true) {
|
|
164
167
|
assertApplyOnly('log_work', { text, projectOverrides, defaultWorklogTask, typeOfWork });
|
|
168
|
+
// Check allowUnbooked and allowDuplicates before the preview is consumed,
|
|
169
|
+
// so the agent can retry the same batchId with the flag set.
|
|
170
|
+
const pending = resolveApprovedBatch({ cache: previews, batchId });
|
|
171
|
+
assertUnbookedAllowed(pending, allowUnbooked);
|
|
172
|
+
assertDuplicatesAllowed(pending, allowDuplicates);
|
|
165
173
|
const approvedBatch = consumeApprovedBatch({ cache: previews, batchId });
|
|
166
|
-
return formatToolResponse(await applyLogworkBatch({ batch: approvedBatch, confirm, allowUnbooked }));
|
|
174
|
+
return formatToolResponse(await applyLogworkBatch({ batch: approvedBatch, confirm, allowUnbooked, allowDuplicates }));
|
|
167
175
|
}
|
|
168
176
|
requirePreviewInput('log_work', { text });
|
|
169
177
|
const preview = await previewLogworkBatch({ text, projectOverrides: projectOverrides || {}, typeOfWork, defaultWorklogTask });
|
|
@@ -203,7 +211,7 @@ server.registerTool('resubmit_entry', {
|
|
|
203
211
|
annotations: WRITE,
|
|
204
212
|
inputSchema: {
|
|
205
213
|
logworkId: LOGWORK_ID_SCHEMA,
|
|
206
|
-
hours: z.number().positive().optional().describe('Preview call only. New hours; defaults to the rejected entry hours.'),
|
|
214
|
+
hours: z.number().positive().max(24).optional().describe('Preview call only. New hours; defaults to the rejected entry hours.'),
|
|
207
215
|
typeOfWork: TYPE_OF_WORK_SCHEMA.optional().describe('Preview call only. New type of work; defaults to the rejected entry value.'),
|
|
208
216
|
noteToPm: z.string().max(500).optional().describe('Preview call only. Note to the project manager explaining the fix.'),
|
|
209
217
|
description: z.string().max(1_000).optional().describe('Preview call only. New description; defaults to the rejected entry description.'),
|
|
@@ -257,8 +265,7 @@ server.registerTool('log_jira_work', {
|
|
|
257
265
|
prunePreviewCache(jiraPreviews);
|
|
258
266
|
if (confirm === true) {
|
|
259
267
|
assertApplyOnly('log_jira_work', { text });
|
|
260
|
-
|
|
261
|
-
return formatToolResponse(await applyJiraWorklogBatch({ batch: approvedBatch, confirm }));
|
|
268
|
+
return applyCachedJiraWork({ cache: jiraPreviews, batchId, confirm });
|
|
262
269
|
}
|
|
263
270
|
requirePreviewInput('log_jira_work', { text });
|
|
264
271
|
const preview = await previewJiraWorklogBatch({ text });
|
|
@@ -304,18 +311,19 @@ server.registerTool('get_history', {
|
|
|
304
311
|
|
|
305
312
|
server.registerTool('map_project', {
|
|
306
313
|
title: 'Save a project mapping',
|
|
307
|
-
description: 'Save a local ticket-prefix/keyword -> RO project mapping so future log_work previews resolve the project automatically. Writes .logwork.json; requires confirm: true after the user approved the mapping.',
|
|
314
|
+
description: 'Save a local ticket-prefix/keyword/git repo -> RO project mapping so future log_work previews resolve the project automatically and draft_from_commits allocates a repo\'s commits to its project. Pass tickets, repos, or both. Writes .logwork.json; requires confirm: true after the user approved the mapping.',
|
|
308
315
|
annotations: { ...LOCAL_WRITE, idempotentHint: true },
|
|
309
316
|
inputSchema: {
|
|
310
317
|
projectMemberId: z.union([z.string(), z.number()]).optional().describe('RO projectMemberId from list_projects or preview setupSuggestions.'),
|
|
311
318
|
projectName: z.string().optional().describe('Project name to match against the user\'s memberships when projectMemberId is not given.'),
|
|
312
|
-
tickets: z.array(z.string()).
|
|
319
|
+
tickets: z.array(z.string()).optional().describe('Ticket prefixes to map, for example ["SCB"]. At least one ticket prefix or repo is required.'),
|
|
313
320
|
keywords: z.array(z.string()).optional().describe('Optional task keywords to map to the project.'),
|
|
321
|
+
repos: z.array(z.string().min(1)).max(20).optional().describe('Absolute paths of git repos whose commits belong to this project (a subfolder is stored as its repo root). draft_from_commits uses them to pick the project per commit.'),
|
|
314
322
|
scope: z.enum(['user', 'project']).optional().describe('user (default): ~/.logwork-cli/.logwork.json for this machine. project: .logwork.json in the current repo.'),
|
|
315
323
|
confirm: z.boolean().describe('Must be true, and only after explicit user approval.')
|
|
316
324
|
}
|
|
317
|
-
}, withAuthRequiredHandling(async ({ projectMemberId, projectName, tickets, keywords = [], scope = 'user', confirm }) => {
|
|
318
|
-
const result = await upsertProjectMapping({ projectMemberId, projectName, tickets, keywords, scope, confirm });
|
|
325
|
+
}, withAuthRequiredHandling(async ({ projectMemberId, projectName, tickets = [], keywords = [], repos = [], scope = 'user', confirm }) => {
|
|
326
|
+
const result = await upsertProjectMapping({ projectMemberId, projectName, tickets, keywords, repos, scope, confirm });
|
|
319
327
|
return formatToolResponse(result);
|
|
320
328
|
}));
|
|
321
329
|
|
|
@@ -340,15 +348,19 @@ server.registerTool('login', {
|
|
|
340
348
|
|
|
341
349
|
server.registerTool('reminder', {
|
|
342
350
|
title: 'Logwork reminder',
|
|
343
|
-
description: 'The native macOS/Windows weekday logwork reminder. action "status" is read-only. "enable", "disable" and "test" (shows a sample notification) change the OS scheduler or display a notification and require confirm: true after explicit user approval. No credentials are stored in the schedule.',
|
|
351
|
+
description: 'The native macOS/Windows weekday logwork reminder. action "status" is read-only. "enable", "disable" and "test" (shows a sample notification) change the OS scheduler or display a notification and require confirm: true after explicit user approval. When RO hours are missing, the notification also counts today\'s commits in the configured repos. status warns when the Node path in the schedule no longer exists. No credentials are stored in the schedule.',
|
|
344
352
|
annotations: LOCAL_WRITE,
|
|
345
353
|
inputSchema: {
|
|
346
354
|
action: z.enum(['status', 'enable', 'disable', 'test']).describe('status: show config and scheduler state. enable/disable: install or remove the schedule. test: send a sample notification.'),
|
|
347
355
|
time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).optional().describe('enable only. Local time in 24-hour HH:mm. Defaults to 17:00.'),
|
|
348
356
|
target: z.enum(['ro', 'jira', 'both']).optional().describe('enable/test only. What to check. Defaults to both (enable) or the configured target (test).'),
|
|
357
|
+
repos: z.array(z.string().min(1)).max(20).optional().describe('enable only. Absolute paths of git repos whose commits today the reminder counts when RO hours are missing. Replaces the saved list; [] clears it; omit to keep it.'),
|
|
349
358
|
confirm: z.boolean().optional().describe('Required (true) for enable, disable and test, after explicit user approval.')
|
|
350
359
|
}
|
|
351
|
-
}, async ({ action, time, target, confirm }) => {
|
|
360
|
+
}, async ({ action, time, target, repos, confirm }) => {
|
|
361
|
+
if (action !== 'enable' && repos !== undefined) {
|
|
362
|
+
throw new Error('repos is only valid when enabling the reminder.');
|
|
363
|
+
}
|
|
352
364
|
if (action === 'status') {
|
|
353
365
|
return formatToolResponse(await getLogworkReminderStatus());
|
|
354
366
|
}
|
|
@@ -362,7 +374,7 @@ server.registerTool('reminder', {
|
|
|
362
374
|
throw new Error('target is not valid when disabling the reminder.');
|
|
363
375
|
}
|
|
364
376
|
if (action === 'enable') {
|
|
365
|
-
return formatToolResponse(await enableLogworkReminder({ time, target, confirm }));
|
|
377
|
+
return formatToolResponse(await enableLogworkReminder({ time, target, repos, confirm }));
|
|
366
378
|
}
|
|
367
379
|
if (action === 'disable') {
|
|
368
380
|
return formatToolResponse(await disableLogworkReminder({ confirm }));
|
|
@@ -536,7 +548,7 @@ export function resolveApprovedBatch({
|
|
|
536
548
|
|
|
537
549
|
const cached = getCachedPreviewRecord(cache, batchId, now);
|
|
538
550
|
if (!cached) {
|
|
539
|
-
throw new Error('No cached log_work preview for this batchId (expired after 1 hour, already
|
|
551
|
+
throw new Error('No cached log_work preview for this batchId (expired after 1 hour, already used by an earlier confirm call, or the server restarted; an earlier confirm may have failed without writing, so check get_logwork before assuming it was applied). Preview again and apply using the returned batchId.');
|
|
540
552
|
}
|
|
541
553
|
|
|
542
554
|
if (batch && createPreviewFingerprint(batch) !== cached.fingerprint) {
|
|
@@ -546,6 +558,20 @@ export function resolveApprovedBatch({
|
|
|
546
558
|
return clonePreview(cached.preview);
|
|
547
559
|
}
|
|
548
560
|
|
|
561
|
+
export function assertDuplicatesAllowed(batch, allowDuplicates) {
|
|
562
|
+
const duplicates = (batch?.entries || []).filter((entry) => entry.possibleDuplicate);
|
|
563
|
+
if (duplicates.length && allowDuplicates !== true) {
|
|
564
|
+
throw new Error(`Nothing was written. ${formatDuplicateBlock(duplicates)} Ask the user whether this is new work, then call log_work again with the same batchId, confirm: true and allowDuplicates: true.`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export function assertUnbookedAllowed(batch, allowUnbooked) {
|
|
569
|
+
const unbooked = (batch?.entries || []).filter((entry) => entry.requiresAllowUnbooked);
|
|
570
|
+
if (unbooked.length && allowUnbooked !== true) {
|
|
571
|
+
throw new Error(`Nothing was written. ${unbooked.length} entr${unbooked.length === 1 ? 'y has' : 'ies have'} no booking on that date (${unbooked.map((entry) => entry.id).join(', ')}). Ask the user, then call log_work again with the same batchId, confirm: true and allowUnbooked: true.`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
549
575
|
export function consumeApprovedBatch({
|
|
550
576
|
cache,
|
|
551
577
|
batchId,
|
|
@@ -562,6 +588,19 @@ export function consumeApprovedBatch({
|
|
|
562
588
|
return approvedBatch;
|
|
563
589
|
}
|
|
564
590
|
|
|
591
|
+
// The log_jira_work apply call. With LOGWORK_DRY_RUN=1 the apply sends no
|
|
592
|
+
// worklog and the response summary starts with "DRY RUN".
|
|
593
|
+
export async function applyCachedJiraWork({
|
|
594
|
+
cache,
|
|
595
|
+
batchId,
|
|
596
|
+
confirm,
|
|
597
|
+
applyOptions = {},
|
|
598
|
+
now = Date.now()
|
|
599
|
+
}) {
|
|
600
|
+
const approvedBatch = consumeApprovedJiraBatch({ cache, batchId, now });
|
|
601
|
+
return formatToolResponse(await applyJiraWorklogBatch({ ...applyOptions, batch: approvedBatch, confirm }));
|
|
602
|
+
}
|
|
603
|
+
|
|
565
604
|
export function resolveApprovedJiraBatch({
|
|
566
605
|
cache,
|
|
567
606
|
batchId,
|
|
@@ -578,7 +617,7 @@ export function resolveApprovedJiraBatch({
|
|
|
578
617
|
|
|
579
618
|
const cached = getCachedPreviewRecord(cache, batchId, now);
|
|
580
619
|
if (!cached) {
|
|
581
|
-
throw new Error('No cached log_jira_work preview for this batchId (expired after 1 hour, already
|
|
620
|
+
throw new Error('No cached log_jira_work preview for this batchId (expired after 1 hour, already used by an earlier confirm call, or the server restarted; an earlier confirm may have failed without writing, so check get_logwork before assuming it was applied). Preview again and apply using the returned batchId.');
|
|
582
621
|
}
|
|
583
622
|
|
|
584
623
|
if (batch && createJiraPreviewFingerprint(batch) !== cached.fingerprint) {
|
|
@@ -620,7 +659,7 @@ export function resolveApprovedRoEditPreview({
|
|
|
620
659
|
|
|
621
660
|
const cached = getCachedPreviewRecord(cache, previewId, now);
|
|
622
661
|
if (!cached) {
|
|
623
|
-
throw new Error(`No cached ${toolName} preview for this previewId (expired after 1 hour, already
|
|
662
|
+
throw new Error(`No cached ${toolName} preview for this previewId (expired after 1 hour, already used by an earlier confirm call, or the server restarted; an earlier confirm may have failed without writing, so check get_logwork before assuming it was applied). Preview again before applying.`);
|
|
624
663
|
}
|
|
625
664
|
if (preview && createRoEditPreviewFingerprint(preview) !== cached.fingerprint) {
|
|
626
665
|
throw new Error(`Approved ${toolName} preview changed after preview. Preview again before applying.`);
|
|
@@ -673,7 +712,10 @@ export function createPreviewFingerprint(preview = {}) {
|
|
|
673
712
|
confidence: numberOrNull(entry?.confidence),
|
|
674
713
|
matchedProject: projectFingerprint(entry?.matchedProject),
|
|
675
714
|
booked: booleanOrNull(entry?.booked),
|
|
676
|
-
requiresAllowUnbooked: booleanOrNull(entry?.requiresAllowUnbooked)
|
|
715
|
+
requiresAllowUnbooked: booleanOrNull(entry?.requiresAllowUnbooked),
|
|
716
|
+
possibleDuplicate: entry?.possibleDuplicate
|
|
717
|
+
? textOrNull(entry.possibleDuplicate.logworkId ?? entry.possibleDuplicate.entryId)
|
|
718
|
+
: null
|
|
677
719
|
}))
|
|
678
720
|
});
|
|
679
721
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { atomicWriteFile } from './atomic-file.mjs';
|
|
3
|
+
import { runtimeInfoPath } from './paths.mjs';
|
|
4
|
+
|
|
5
|
+
// The node binary that runs the MCP server and the reminder is stored as an
|
|
6
|
+
// absolute path (IDE configs, the launchd plist, the Windows task). With nvm,
|
|
7
|
+
// Homebrew or a Node installer upgrade that path can disappear or stop being
|
|
8
|
+
// the node the user runs. `logwork setup` records it here so `logwork doctor`
|
|
9
|
+
// and `logwork reminder status` can tell when it went stale.
|
|
10
|
+
|
|
11
|
+
export async function writeRuntimeInfo({
|
|
12
|
+
path = runtimeInfoPath(),
|
|
13
|
+
nodePath = process.execPath,
|
|
14
|
+
nodeVersion = process.version,
|
|
15
|
+
version = null,
|
|
16
|
+
now = () => new Date(),
|
|
17
|
+
writeFile = atomicWriteFile
|
|
18
|
+
} = {}) {
|
|
19
|
+
const info = {
|
|
20
|
+
nodePath: String(nodePath || ''),
|
|
21
|
+
nodeVersion: String(nodeVersion || ''),
|
|
22
|
+
installedAt: now().toISOString(),
|
|
23
|
+
version: version ? String(version) : null
|
|
24
|
+
};
|
|
25
|
+
await writeFile(path, `${JSON.stringify(info, null, 2)}\n`);
|
|
26
|
+
return info;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function loadRuntimeInfo({
|
|
30
|
+
path = runtimeInfoPath(),
|
|
31
|
+
readFile = fs.readFile
|
|
32
|
+
} = {}) {
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error.code === 'ENOENT' || error instanceof SyntaxError) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
if (!parsed || typeof parsed !== 'object' || typeof parsed.nodePath !== 'string' || !parsed.nodePath) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
nodePath: parsed.nodePath,
|
|
47
|
+
nodeVersion: typeof parsed.nodeVersion === 'string' ? parsed.nodeVersion : '',
|
|
48
|
+
installedAt: typeof parsed.installedAt === 'string' ? parsed.installedAt : '',
|
|
49
|
+
version: typeof parsed.version === 'string' ? parsed.version : null
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ok: the recorded node is the current one (same file after symlinks).
|
|
54
|
+
// missing: the recorded node no longer exists.
|
|
55
|
+
// stale: it exists but differs from the current, working node.
|
|
56
|
+
export async function inspectNodePath(recordedPath, {
|
|
57
|
+
currentPath = process.execPath,
|
|
58
|
+
platform = process.platform,
|
|
59
|
+
exists = pathExists,
|
|
60
|
+
realpath = fs.realpath
|
|
61
|
+
} = {}) {
|
|
62
|
+
const recorded = String(recordedPath || '');
|
|
63
|
+
if (!recorded) {
|
|
64
|
+
return { status: 'unknown', recordedPath: '', currentPath };
|
|
65
|
+
}
|
|
66
|
+
if (!await exists(recorded)) {
|
|
67
|
+
return { status: 'missing', recordedPath: recorded, currentPath };
|
|
68
|
+
}
|
|
69
|
+
const [left, right] = await Promise.all([
|
|
70
|
+
realpath(recorded).catch(() => recorded),
|
|
71
|
+
realpath(currentPath).catch(() => currentPath)
|
|
72
|
+
]);
|
|
73
|
+
const same = platform === 'win32'
|
|
74
|
+
? String(left).toLowerCase() === String(right).toLowerCase()
|
|
75
|
+
: left === right;
|
|
76
|
+
if (same) {
|
|
77
|
+
return { status: 'ok', recordedPath: recorded, currentPath };
|
|
78
|
+
}
|
|
79
|
+
if (!await exists(currentPath)) {
|
|
80
|
+
// Not stale: there is no working node to switch to.
|
|
81
|
+
return { status: 'differs', recordedPath: recorded, currentPath };
|
|
82
|
+
}
|
|
83
|
+
return { status: 'stale', recordedPath: recorded, currentPath };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// source "runtime": the node recorded by `logwork setup` (used by the printed
|
|
87
|
+
// MCP configs). source "reminder": the node in the installed OS schedule.
|
|
88
|
+
export function formatNodePathIssue(source, inspection) {
|
|
89
|
+
const { status, recordedPath, currentPath } = inspection || {};
|
|
90
|
+
if (source === 'reminder') {
|
|
91
|
+
if (status === 'missing') {
|
|
92
|
+
return `Reminder: the OS schedule runs Node at ${recordedPath}, which no longer exists, so the reminder cannot run. Fix: run \`logwork reminder enable\` (or \`logwork setup\`) to re-register it with the current Node.`;
|
|
93
|
+
}
|
|
94
|
+
if (status === 'stale') {
|
|
95
|
+
return `Reminder: the OS schedule runs Node at ${recordedPath}, but the current Node is ${currentPath}. Fix: run \`logwork reminder enable\` to re-register it with the current Node.`;
|
|
96
|
+
}
|
|
97
|
+
return '';
|
|
98
|
+
}
|
|
99
|
+
if (status === 'missing') {
|
|
100
|
+
return `Runtime: \`logwork setup\` recorded Node at ${recordedPath}, which no longer exists, so IDE MCP configs that use it cannot start. Fix: run \`logwork setup\` and paste the new MCP config "command" into your IDE.`;
|
|
101
|
+
}
|
|
102
|
+
if (status === 'stale') {
|
|
103
|
+
return `Runtime: \`logwork setup\` recorded Node at ${recordedPath}, but the current Node is ${currentPath}. Fix: if you switched Node on purpose, run \`logwork setup\` and update the MCP config "command" in your IDE.`;
|
|
104
|
+
}
|
|
105
|
+
return '';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function pathExists(path) {
|
|
109
|
+
try {
|
|
110
|
+
await fs.access(path);
|
|
111
|
+
return true;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// How to start npm as a child process on every platform.
|
|
5
|
+
// On Windows, npm is npm.cmd: spawn('npm') fails with ENOENT, and since the
|
|
6
|
+
// Node fix for CVE-2024-27980 spawn('npm.cmd') without a shell fails with
|
|
7
|
+
// EINVAL. Running npm's own npm-cli.js with the current node avoids the shell;
|
|
8
|
+
// when it cannot be found, fall back to npm.cmd through cmd.exe.
|
|
9
|
+
export function npmInvocation(args, {
|
|
10
|
+
platform = process.platform,
|
|
11
|
+
execPath = process.execPath,
|
|
12
|
+
env = process.env,
|
|
13
|
+
exists = existsSync
|
|
14
|
+
} = {}) {
|
|
15
|
+
if (platform !== 'win32') {
|
|
16
|
+
return { command: 'npm', args, shell: false };
|
|
17
|
+
}
|
|
18
|
+
const candidates = [
|
|
19
|
+
env.npm_execpath && /npm-cli\.js$/i.test(env.npm_execpath) ? env.npm_execpath : null,
|
|
20
|
+
join(dirname(execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js')
|
|
21
|
+
].filter(Boolean);
|
|
22
|
+
const cli = candidates.find((path) => exists(path));
|
|
23
|
+
if (cli) {
|
|
24
|
+
return { command: execPath, args: [cli, ...args], shell: false };
|
|
25
|
+
}
|
|
26
|
+
// cmd.exe joins the arguments unquoted; the arguments used here have no
|
|
27
|
+
// spaces or shell metacharacters.
|
|
28
|
+
return { command: 'npm.cmd', args, shell: true };
|
|
29
|
+
}
|
package/lib/paths.mjs
CHANGED
|
@@ -9,6 +9,7 @@ export const APPLY_LEDGER_FILE_NAME = 'apply-ledger.json';
|
|
|
9
9
|
export const UPDATE_STATE_FILE_NAME = 'update-state.json';
|
|
10
10
|
export const REMINDER_CONFIG_FILE_NAME = 'reminder.json';
|
|
11
11
|
export const REMINDER_STATE_FILE_NAME = 'reminder-state.json';
|
|
12
|
+
export const RUNTIME_INFO_FILE_NAME = 'runtime.json';
|
|
12
13
|
|
|
13
14
|
// Names used before the package was renamed from logwork-helper to logwork-cli.
|
|
14
15
|
// lib/legacy-migration.mjs copies state from these locations on first run.
|
|
@@ -51,8 +52,17 @@ export function reminderStatePath() {
|
|
|
51
52
|
return resolve(helperHome(), REMINDER_STATE_FILE_NAME);
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
// Written by `logwork setup`: the node binary and version the installed
|
|
56
|
+
// runtime, MCP configs and reminder were set up with.
|
|
57
|
+
export function runtimeInfoPath() {
|
|
58
|
+
return resolve(helperHome(), RUNTIME_INFO_FILE_NAME);
|
|
59
|
+
}
|
|
60
|
+
|
|
54
61
|
export function legacyUserConfigPath() {
|
|
55
|
-
|
|
62
|
+
// A custom home (tests, separate profiles) keeps its state isolated from
|
|
63
|
+
// the real ~/.logwork-helper.json.
|
|
64
|
+
const custom = customHelperHome();
|
|
65
|
+
return custom ? resolve(custom, LEGACY_CONFIG_FILE_NAME) : resolve(homedir(), LEGACY_CONFIG_FILE_NAME);
|
|
56
66
|
}
|
|
57
67
|
|
|
58
68
|
// Project-scope mappings live in the repo the user works in. A repo that still
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
1
3
|
import { ApiError, getProjects } from './api.mjs';
|
|
4
|
+
import { resolveGitRepoRoot } from './commit-workflow.mjs';
|
|
2
5
|
import {
|
|
3
6
|
configPath,
|
|
4
7
|
loadLocalConfig,
|
|
@@ -34,17 +37,35 @@ export async function upsertProjectMapping({
|
|
|
34
37
|
projectName,
|
|
35
38
|
tickets = [],
|
|
36
39
|
keywords = [],
|
|
40
|
+
repos = [],
|
|
37
41
|
scope = 'user',
|
|
38
42
|
confirm,
|
|
39
43
|
cwd = process.cwd(),
|
|
40
|
-
fetchProjects
|
|
44
|
+
fetchProjects,
|
|
45
|
+
resolveRepoRoot = resolveGitRepoRoot
|
|
41
46
|
} = {}) {
|
|
42
47
|
if (confirm !== true) {
|
|
43
48
|
throw new Error('map_project requires confirm: true.');
|
|
44
49
|
}
|
|
45
50
|
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
const ticketList = Array.isArray(tickets) ? tickets : [];
|
|
52
|
+
const repoList = Array.isArray(repos) ? repos.filter((repo) => typeof repo === 'string' && repo.trim()) : [];
|
|
53
|
+
if (!ticketList.length && !repoList.length) {
|
|
54
|
+
throw new Error('map_project requires at least one ticket prefix or repo.');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Commit drafts match a commit's git repo root, so a subfolder is stored as
|
|
58
|
+
// the root of the repo that contains it.
|
|
59
|
+
const repoRoots = [];
|
|
60
|
+
for (const repo of repoList) {
|
|
61
|
+
const trimmed = repo.trim();
|
|
62
|
+
const expanded = trimmed === '~' || /^~[\\/]/.test(trimmed) ? `${homedir()}${trimmed.slice(1)}` : trimmed;
|
|
63
|
+
const path = resolve(cwd, expanded);
|
|
64
|
+
try {
|
|
65
|
+
repoRoots.push(await resolveRepoRoot(path));
|
|
66
|
+
} catch (error) {
|
|
67
|
+
throw new Error(`${path} is not a git repository: ${error.message}`);
|
|
68
|
+
}
|
|
48
69
|
}
|
|
49
70
|
|
|
50
71
|
const projects = sanitizeProjects(await (fetchProjects || createMembershipProjectsFetcher())());
|
|
@@ -60,8 +81,9 @@ export async function upsertProjectMapping({
|
|
|
60
81
|
cwd,
|
|
61
82
|
scope,
|
|
62
83
|
project: resolution.project,
|
|
63
|
-
tickets,
|
|
64
|
-
keywords
|
|
84
|
+
tickets: ticketList,
|
|
85
|
+
keywords,
|
|
86
|
+
repos: repoRoots
|
|
65
87
|
});
|
|
66
88
|
|
|
67
89
|
return {
|
|
@@ -72,8 +94,9 @@ export async function upsertProjectMapping({
|
|
|
72
94
|
projects,
|
|
73
95
|
summary: [
|
|
74
96
|
`${result.created ? 'Created' : 'Updated'} project mapping for ${result.mapping.projectName}.`,
|
|
75
|
-
`Tickets: ${result.mapping.tickets.join(', ')}
|
|
76
|
-
result.mapping.keywords.length ? `Keywords: ${result.mapping.keywords.join(', ')}.` : 'Keywords: none.'
|
|
97
|
+
result.mapping.tickets.length ? `Tickets: ${result.mapping.tickets.join(', ')}.` : 'Tickets: none.',
|
|
98
|
+
result.mapping.keywords.length ? `Keywords: ${result.mapping.keywords.join(', ')}.` : 'Keywords: none.',
|
|
99
|
+
result.mapping.repos?.length ? `Repos: ${result.mapping.repos.join(', ')}.` : 'Repos: none.'
|
|
77
100
|
].join('\n')
|
|
78
101
|
};
|
|
79
102
|
}
|
package/lib/query-workflow.mjs
CHANGED
package/lib/release-doctor.mjs
CHANGED
|
@@ -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
|
|
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:
|
|
121
|
+
shell: invocation.shell,
|
|
120
122
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
121
123
|
});
|
|
122
124
|
let stdout = '';
|