logwork-cli 1.1.1 → 1.3.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 +53 -25
- package/RELEASE.md +4 -2
- package/bin/logwork.mjs +2 -2
- package/commands/commits.mjs +128 -13
- package/commands/history.mjs +0 -4
- package/commands/mcp.mjs +266 -0
- package/commands/quick.mjs +38 -23
- package/commands/reminder.mjs +41 -2
- package/commands/setup.mjs +197 -42
- package/commands/tui.mjs +1 -0
- package/docs/advanced.md +18 -7
- package/docs/mcp-setup.md +120 -26
- package/docs/operations-guide.md +50 -2
- package/docs/recipes.md +40 -0
- package/docs/security.md +23 -2
- package/examples/mcp/antigravity.mcp_config.json +2 -2
- package/examples/mcp/codex.config.toml +2 -2
- package/examples/mcp/copilot.mcp.json +2 -2
- 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-workflow.mjs +187 -2
- package/lib/commit-workflow.mjs +345 -23
- package/lib/config.mjs +7 -0
- package/lib/diagnostics.mjs +116 -4
- package/lib/jira-api.mjs +25 -3
- package/lib/jira-workflow.mjs +36 -6
- package/lib/launcher.mjs +303 -0
- 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 +142 -1282
- package/lib/manual-ink-draft-actions.mjs +227 -0
- package/lib/manual-ink-interaction.mjs +318 -0
- package/lib/manual-ink-session.mjs +41 -0
- package/lib/manual-ink-task-editor.mjs +338 -0
- package/lib/manual-ink-ui.mjs +38 -0
- package/lib/manual-ink-wizard.mjs +520 -0
- package/lib/manual-mcp.mjs +78 -20
- package/lib/manual-repl.mjs +171 -22
- package/lib/mcp-install.mjs +930 -0
- package/lib/mcp-server.mjs +51 -20
- package/lib/node-runtime.mjs +155 -0
- package/lib/paths.mjs +7 -0
- package/lib/project-mapping-workflow.mjs +32 -27
- package/lib/query-workflow.mjs +19 -1
- package/lib/reconciliation-workflow.mjs +1 -1
- package/lib/reminder-config.mjs +38 -2
- package/lib/reminder-scheduler.mjs +85 -18
- package/lib/reminder-service.mjs +42 -3
- package/lib/reminder-workflow.mjs +118 -22
- package/lib/repo-project-suggestions.mjs +285 -0
- 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 +2 -1
- package/lib/ro-entry-reader.mjs +11 -6
- package/lib/ro-resubmit-workflow.mjs +2 -1
- package/lib/terminal-auth-prompts.mjs +7 -4
- package/lib/ui.mjs +121 -56
- package/package.json +11 -4
- package/skills/logwork/SKILL.md +10 -4
package/lib/ui.mjs
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as clackPrompts from '@clack/prompts';
|
|
2
2
|
import { CONFIG } from './config.mjs';
|
|
3
|
+
import { formatSuggestionReason, orderProjectsForPicker } from './repo-project-suggestions.mjs';
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
// Every prompt function takes an optional `{ prompts }` (default: @clack/prompts)
|
|
6
|
+
// so tests can answer prompts without a terminal.
|
|
7
|
+
|
|
8
|
+
export async function chooseProject(projects, daySummaries, { prompts = clackPrompts } = {}) {
|
|
9
|
+
const options = projectSelectOptions(projects, daySummaries);
|
|
10
|
+
|
|
11
|
+
const projectMemberId = handleCancel(await prompts.select({
|
|
12
|
+
message: 'Chọn project cần log work:',
|
|
13
|
+
options
|
|
14
|
+
}), prompts);
|
|
15
|
+
|
|
16
|
+
return projects.find((project) => project.projectMemberId === projectMemberId);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function projectSelectOptions(projects, daySummaries) {
|
|
20
|
+
return projects.map((project, index) => {
|
|
6
21
|
const summary = daySummaries[index];
|
|
7
22
|
const logged = summary?.ok ? `${formatHours(summary.totalHours)}h` : `${formatHours(project.loggedHours)}h`;
|
|
8
23
|
const booked = `${formatHours(project.assignedHours)}h`;
|
|
@@ -12,88 +27,130 @@ export async function chooseProject(projects, daySummaries) {
|
|
|
12
27
|
label: `${project.projectName} - ${booked}/day (${project.workloadPercent}% assigned) · ${logged} logged today`
|
|
13
28
|
};
|
|
14
29
|
});
|
|
30
|
+
}
|
|
15
31
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
32
|
+
export const SKIP_REPO_PROJECT = '__skip__';
|
|
33
|
+
|
|
34
|
+
// Options for "which RO project does this repo belong to?": suggested projects
|
|
35
|
+
// first (reason as hint), then every other member project, then skip. Values
|
|
36
|
+
// are option keys; resolve them with the returned items.
|
|
37
|
+
export function repoProjectSelectOptions(suggestions = [], candidates = [], { skipLabel = 'Skip for now' } = {}) {
|
|
38
|
+
const items = orderProjectsForPicker(candidates, suggestions);
|
|
39
|
+
const options = items.map((item, index) => ({
|
|
40
|
+
value: `project:${index}`,
|
|
41
|
+
label: item.project.projectName || `project member ${item.project.projectMemberId}`,
|
|
42
|
+
...(item.suggestion ? { hint: formatSuggestionReason(item.suggestion) } : {})
|
|
19
43
|
}));
|
|
44
|
+
options.push({ value: SKIP_REPO_PROJECT, label: skipLabel });
|
|
45
|
+
return {
|
|
46
|
+
items,
|
|
47
|
+
options,
|
|
48
|
+
initialValue: options[0].value
|
|
49
|
+
};
|
|
50
|
+
}
|
|
20
51
|
|
|
21
|
-
|
|
52
|
+
export function repoProjectFromOption(items, value) {
|
|
53
|
+
const match = /^project:(\d+)$/.exec(String(value || ''));
|
|
54
|
+
return match ? items[Number(match[1])]?.project || null : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Asks which project a repo belongs to. Returns the chosen project, or null
|
|
58
|
+
// when the user skips (Escape / Ctrl+C also skip).
|
|
59
|
+
export async function chooseRepoProject({ repoName, suggestions = [], candidates = [] }, { prompts = clackPrompts } = {}) {
|
|
60
|
+
const { items, options, initialValue } = repoProjectSelectOptions(suggestions, candidates);
|
|
61
|
+
if (!items.length) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const value = await prompts.select({
|
|
65
|
+
message: `Which RO project does ${repoName} belong to?`,
|
|
66
|
+
options,
|
|
67
|
+
initialValue
|
|
68
|
+
});
|
|
69
|
+
if (prompts.isCancel(value)) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
return repoProjectFromOption(items, value);
|
|
22
73
|
}
|
|
23
74
|
|
|
24
|
-
export async function chooseTask(tasks) {
|
|
25
|
-
const taskId = handleCancel(await select({
|
|
75
|
+
export async function chooseTask(tasks, { prompts = clackPrompts } = {}) {
|
|
76
|
+
const taskId = handleCancel(await prompts.select({
|
|
26
77
|
message: 'Chọn loại công việc:',
|
|
27
|
-
options: tasks
|
|
28
|
-
|
|
29
|
-
label: `${task.name} (${task.typeOfWork})`,
|
|
30
|
-
hint: task.description
|
|
31
|
-
}))
|
|
32
|
-
}));
|
|
78
|
+
options: taskSelectOptions(tasks)
|
|
79
|
+
}), prompts);
|
|
33
80
|
|
|
34
81
|
return tasks.find((task) => task.taskId === taskId);
|
|
35
82
|
}
|
|
36
83
|
|
|
37
|
-
export
|
|
38
|
-
|
|
84
|
+
export function taskSelectOptions(tasks) {
|
|
85
|
+
return tasks.map((task) => ({
|
|
86
|
+
value: task.taskId,
|
|
87
|
+
label: `${task.name} (${task.typeOfWork})`,
|
|
88
|
+
hint: task.description
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function chooseMessageSource(defaultMessage, { prompts = clackPrompts } = {}) {
|
|
93
|
+
const source = handleCancel(await prompts.select({
|
|
39
94
|
message: 'Task message:',
|
|
40
95
|
options: [
|
|
41
96
|
{ value: 'git', label: 'Use git commit message' },
|
|
42
97
|
{ value: 'custom', label: 'Custom' }
|
|
43
98
|
],
|
|
44
99
|
initialValue: 'git'
|
|
45
|
-
}));
|
|
100
|
+
}), prompts);
|
|
46
101
|
|
|
47
102
|
const initialValue = source === 'git' ? defaultMessage : '';
|
|
48
|
-
const value = handleCancel(await text({
|
|
103
|
+
const value = handleCancel(await prompts.text({
|
|
49
104
|
message: 'Nội dung công việc:',
|
|
50
105
|
placeholder: 'Fix login flow',
|
|
51
106
|
initialValue,
|
|
52
|
-
validate
|
|
53
|
-
|
|
54
|
-
return 'Vui lòng nhập nội dung công việc.';
|
|
55
|
-
}
|
|
56
|
-
return undefined;
|
|
57
|
-
}
|
|
58
|
-
}));
|
|
107
|
+
validate: validateTaskMessage
|
|
108
|
+
}), prompts);
|
|
59
109
|
|
|
60
110
|
return value.trim();
|
|
61
111
|
}
|
|
62
112
|
|
|
63
|
-
export
|
|
64
|
-
|
|
113
|
+
export function validateTaskMessage(input) {
|
|
114
|
+
if (!input || input.trim() === '') {
|
|
115
|
+
return 'Vui lòng nhập nội dung công việc.';
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function askHours(defaultHours = CONFIG.defaultHours, { prompts = clackPrompts } = {}) {
|
|
121
|
+
const value = handleCancel(await prompts.text({
|
|
65
122
|
message: 'Số giờ log:',
|
|
66
123
|
placeholder: String(defaultHours),
|
|
67
124
|
initialValue: String(defaultHours),
|
|
68
125
|
validate(input) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (!Number.isFinite(numeric)) {
|
|
72
|
-
return 'Số giờ phải là số.';
|
|
73
|
-
}
|
|
74
|
-
if (numeric <= 0) {
|
|
75
|
-
return 'Số giờ phải lớn hơn 0.';
|
|
76
|
-
}
|
|
77
|
-
if (!Number.isInteger(numeric / CONFIG.hourStep)) {
|
|
78
|
-
return `Số giờ phải chia hết cho ${CONFIG.hourStep}.`;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return undefined;
|
|
126
|
+
return validateHours(input);
|
|
82
127
|
}
|
|
83
|
-
}));
|
|
128
|
+
}), prompts);
|
|
84
129
|
|
|
85
130
|
return Number(value);
|
|
86
131
|
}
|
|
87
132
|
|
|
88
|
-
export
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
133
|
+
export function validateHours(input, hourStep = CONFIG.hourStep) {
|
|
134
|
+
const numeric = Number(input);
|
|
135
|
+
|
|
136
|
+
if (!Number.isFinite(numeric)) {
|
|
137
|
+
return 'Số giờ phải là số.';
|
|
138
|
+
}
|
|
139
|
+
if (numeric <= 0) {
|
|
140
|
+
return 'Số giờ phải lớn hơn 0.';
|
|
141
|
+
}
|
|
142
|
+
if (!Number.isInteger(numeric / hourStep)) {
|
|
143
|
+
return `Số giờ phải chia hết cho ${hourStep}.`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function confirmSubmit(payloadPreview, { prompts = clackPrompts } = {}) {
|
|
150
|
+
const ok = handleCancel(await prompts.confirm({
|
|
151
|
+
message: formatSubmitConfirmation(payloadPreview),
|
|
95
152
|
initialValue: true
|
|
96
|
-
}));
|
|
153
|
+
}), prompts);
|
|
97
154
|
|
|
98
155
|
if (!ok) {
|
|
99
156
|
throw new Error('User declined final submit.');
|
|
@@ -102,16 +159,24 @@ export async function confirmSubmit(payloadPreview) {
|
|
|
102
159
|
return true;
|
|
103
160
|
}
|
|
104
161
|
|
|
105
|
-
export function
|
|
106
|
-
|
|
162
|
+
export function formatSubmitConfirmation(payloadPreview) {
|
|
163
|
+
const task = truncate(payloadPreview.taskName, 80);
|
|
164
|
+
const description = payloadPreview.description
|
|
165
|
+
? ` | "${truncate(payloadPreview.description, 120)}"`
|
|
166
|
+
: '';
|
|
167
|
+
return `Log ${payloadPreview.hours}h to ${payloadPreview.projectName} with task "${task}"${description}?`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function showSuccess(message, { prompts = clackPrompts } = {}) {
|
|
171
|
+
prompts.outro(message);
|
|
107
172
|
}
|
|
108
173
|
|
|
109
|
-
export function showError(error) {
|
|
110
|
-
cancel(error?.message || String(error));
|
|
174
|
+
export function showError(error, { prompts = clackPrompts } = {}) {
|
|
175
|
+
prompts.cancel(error?.message || String(error));
|
|
111
176
|
}
|
|
112
177
|
|
|
113
|
-
export function handleCancel(value) {
|
|
114
|
-
if (isCancel(value)) {
|
|
178
|
+
export function handleCancel(value, prompts = clackPrompts) {
|
|
179
|
+
if (prompts.isCancel(value)) {
|
|
115
180
|
throw new Error('Cancelled.');
|
|
116
181
|
}
|
|
117
182
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "logwork-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Log work to Resource Optimiser and Jira from the terminal, an MCP server for AI IDEs, and a terminal UI.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -58,9 +58,11 @@
|
|
|
58
58
|
"audit:prod": "npm audit --omit=dev --audit-level=moderate",
|
|
59
59
|
"pack:check": "npm pack --dry-run",
|
|
60
60
|
"release:doctor": "node bin/logwork.mjs release doctor --full",
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"test": "node --test"
|
|
61
|
+
"lint": "eslint .",
|
|
62
|
+
"release:check": "npm run lint && npm test && npm run audit:prod && npm run pack:check && git diff --check",
|
|
63
|
+
"test:windows-credential": "node --import ./test/helpers/setup.mjs --test --test-name-pattern=\"Windows Credential Manager stores, reads, and deletes a test credential\" test/credential-store.test.mjs",
|
|
64
|
+
"test": "node --import ./test/helpers/setup.mjs --test \"test/*.test.mjs\"",
|
|
65
|
+
"test:coverage": "node --import ./test/helpers/setup.mjs --test --experimental-test-coverage --test-coverage-include=\"bin/**\" --test-coverage-include=\"commands/**\" --test-coverage-include=\"lib/**\" --test-coverage-lines=78 --test-coverage-branches=75 --test-coverage-functions=85 \"test/*.test.mjs\""
|
|
64
66
|
},
|
|
65
67
|
"dependencies": {
|
|
66
68
|
"@clack/prompts": "^1.5.0",
|
|
@@ -71,5 +73,10 @@
|
|
|
71
73
|
"ink-text-input": "^6.0.0",
|
|
72
74
|
"react": "^19.2.7",
|
|
73
75
|
"zod": "^4.4.3"
|
|
76
|
+
},
|
|
77
|
+
"devDependencies": {
|
|
78
|
+
"@eslint/js": "^9.39.5",
|
|
79
|
+
"eslint": "^9.39.5",
|
|
80
|
+
"globals": "^17.14.0"
|
|
74
81
|
}
|
|
75
82
|
}
|
package/skills/logwork/SKILL.md
CHANGED
|
@@ -10,19 +10,25 @@ Goal: every logged line is backed by real work (the user's commits or what the u
|
|
|
10
10
|
## Steps
|
|
11
11
|
|
|
12
12
|
1. **Find the gaps.** Call `get_logwork` for the period. A day/project still needs time when booked > logged. Skip days that are already full.
|
|
13
|
-
2. **Collect the evidence.** Call `draft_from_commits` with the same period and `repos` set to every repo the user worked in (the open workspace folders; ask the user for others). It groups the user's commits per day by repo, ticket and kind of work, suggests a worklog task per group, and splits each
|
|
13
|
+
2. **Collect the evidence.** Call `draft_from_commits` with the same period and `repos` set to every repo the user worked in (the open workspace folders; ask the user for others). It groups the user's commits per day by repo, ticket and kind of work, suggests a worklog task per group, picks each group's RO project (a `map_project` mapping whose `repos` holds the repo, else ticket/keyword mappings and that day's bookings), and splits each project's remaining RO hours across its groups. It returns `draftText` and `projectOverrides` (preview entry id → `projectMemberId`). Treat its hours as a starting point, not a fact.
|
|
14
14
|
3. **Check the worklog tasks.** For each project involved, call `list_tasks(projectId)`. Keep the suggested task when it exists in that catalog; otherwise pick the catalog task whose description matches the work.
|
|
15
15
|
4. **Write the descriptions.** One line per piece of work: what was done and the outcome, with ticket keys, in English, under about 200 characters, no `|`. Rewrite vague commit subjects ("wip", "fix", "update"); use `get_jira_issue` to read the ticket when the subject alone is unclear.
|
|
16
16
|
5. **Set the hours.**
|
|
17
|
-
- Stay within the remaining booked hours per day and project unless the user says otherwise. The draft
|
|
17
|
+
- Stay within the remaining booked hours per day and project unless the user says otherwise. The draft already gives each project at most its own remaining hours; lines without a project share the room of booked projects no other line claimed. A project with no room left gets 0h, and the summary says so.
|
|
18
18
|
- If `draft_from_commits` returns `authRequired`, call `login` first; do not guess hours without RO.
|
|
19
19
|
- Use 0.5h steps.
|
|
20
20
|
- Do not log weekends or days without a booking unless the user asks.
|
|
21
21
|
- Work without commits (meetings, reviews, support) only comes from the user; ask, then log it as `Other Activities` or `Code Review & Refactoring`.
|
|
22
|
-
6. **Resolve projects.**
|
|
22
|
+
6. **Resolve projects.** The draft's `projectOverrides` already name the project of every resolved line. For each repo in `unmappedRepos` (lines with no project):
|
|
23
|
+
- Take the top entry of `repoSuggestions[repo]` (ranked by ticket history, repo/remote name and single booked project; `reasons` say why).
|
|
24
|
+
- Ask one short question per repo, by project name: "Is `<repo name>` part of **<projectName>**? (<first reason>)". With no suggestion, ask which project it belongs to and offer names from `projectCandidates`.
|
|
25
|
+
- On a yes (or the user's own pick), call `map_project` with `projectName` (or that suggestion's `projectMemberId`), `repos: [repo]` and `confirm: true`. Never ask the user for ids.
|
|
26
|
+
- Then run `draft_from_commits` again so those lines get their project and hours.
|
|
27
|
+
A line `log_work` still cannot resolve: ask the user and add its id to `projectOverrides`.
|
|
23
28
|
7. **Get approval, then write.**
|
|
24
29
|
- Show the final block to the user.
|
|
25
|
-
- On approval, call `log_work(text)` and show its preview summary.
|
|
30
|
+
- On approval, call `log_work(text, projectOverrides)` and show its preview summary. Pass `projectOverrides` from the draft unchanged when `draftText` is unchanged. Entry ids are `<date>-<NN>` with NN counting lines through the whole text, so if you add, remove or reorder lines, renumber the overrides to match.
|
|
31
|
+
- If the preview flags `possibleDuplicate` entries, show the warnings. They match an existing RO entry (or an earlier line) on date, worklog task, hours and description. Usually drop those lines and preview again. Pass `allowDuplicates: true` on the confirm call only when the user says it is really new work.
|
|
26
32
|
- Then call `log_work(batchId, confirm: true)`.
|
|
27
33
|
- If the user also logs Jira, do the same with `log_jira_work`. Jira needs exactly one issue key per line.
|
|
28
34
|
|