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.
Files changed (70) hide show
  1. package/README.md +53 -25
  2. package/RELEASE.md +4 -2
  3. package/bin/logwork.mjs +2 -2
  4. package/commands/commits.mjs +128 -13
  5. package/commands/history.mjs +0 -4
  6. package/commands/mcp.mjs +266 -0
  7. package/commands/quick.mjs +38 -23
  8. package/commands/reminder.mjs +41 -2
  9. package/commands/setup.mjs +197 -42
  10. package/commands/tui.mjs +1 -0
  11. package/docs/advanced.md +18 -7
  12. package/docs/mcp-setup.md +120 -26
  13. package/docs/operations-guide.md +50 -2
  14. package/docs/recipes.md +40 -0
  15. package/docs/security.md +23 -2
  16. package/examples/mcp/antigravity.mcp_config.json +2 -2
  17. package/examples/mcp/codex.config.toml +2 -2
  18. package/examples/mcp/copilot.mcp.json +2 -2
  19. package/lib/api-auth-forms.mjs +393 -0
  20. package/lib/api-auth-http.mjs +184 -0
  21. package/lib/api-auth-support.mjs +101 -0
  22. package/lib/api-auth-tokens.mjs +129 -0
  23. package/lib/api-auth.mjs +39 -813
  24. package/lib/api.mjs +38 -1422
  25. package/lib/apply-ledger.mjs +1 -1
  26. package/lib/batch-workflow.mjs +187 -2
  27. package/lib/commit-workflow.mjs +345 -23
  28. package/lib/config.mjs +7 -0
  29. package/lib/diagnostics.mjs +116 -4
  30. package/lib/jira-api.mjs +25 -3
  31. package/lib/jira-workflow.mjs +36 -6
  32. package/lib/launcher.mjs +303 -0
  33. package/lib/logwork-config.mjs +64 -13
  34. package/lib/manual-apply-state.mjs +15 -0
  35. package/lib/manual-commands.mjs +2 -2
  36. package/lib/manual-ink-app.mjs +142 -1282
  37. package/lib/manual-ink-draft-actions.mjs +227 -0
  38. package/lib/manual-ink-interaction.mjs +318 -0
  39. package/lib/manual-ink-session.mjs +41 -0
  40. package/lib/manual-ink-task-editor.mjs +338 -0
  41. package/lib/manual-ink-ui.mjs +38 -0
  42. package/lib/manual-ink-wizard.mjs +520 -0
  43. package/lib/manual-mcp.mjs +78 -20
  44. package/lib/manual-repl.mjs +171 -22
  45. package/lib/mcp-install.mjs +930 -0
  46. package/lib/mcp-server.mjs +51 -20
  47. package/lib/node-runtime.mjs +155 -0
  48. package/lib/paths.mjs +7 -0
  49. package/lib/project-mapping-workflow.mjs +32 -27
  50. package/lib/query-workflow.mjs +19 -1
  51. package/lib/reconciliation-workflow.mjs +1 -1
  52. package/lib/reminder-config.mjs +38 -2
  53. package/lib/reminder-scheduler.mjs +85 -18
  54. package/lib/reminder-service.mjs +42 -3
  55. package/lib/reminder-workflow.mjs +118 -22
  56. package/lib/repo-project-suggestions.mjs +285 -0
  57. package/lib/ro-api-diagnostics.mjs +109 -0
  58. package/lib/ro-api-entries.mjs +359 -0
  59. package/lib/ro-api-fields.mjs +290 -0
  60. package/lib/ro-api-http.mjs +68 -0
  61. package/lib/ro-api-projects.mjs +137 -0
  62. package/lib/ro-api-tasks.mjs +50 -0
  63. package/lib/ro-api-timesheet.mjs +413 -0
  64. package/lib/ro-delete-workflow.mjs +2 -1
  65. package/lib/ro-entry-reader.mjs +11 -6
  66. package/lib/ro-resubmit-workflow.mjs +2 -1
  67. package/lib/terminal-auth-prompts.mjs +7 -4
  68. package/lib/ui.mjs +121 -56
  69. package/package.json +11 -4
  70. package/skills/logwork/SKILL.md +10 -4
package/lib/ui.mjs CHANGED
@@ -1,8 +1,23 @@
1
- import { cancel, confirm, isCancel, note, outro, select, text } from '@clack/prompts';
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
- export async function chooseProject(projects, daySummaries) {
5
- const options = projects.map((project, index) => {
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
- const projectMemberId = handleCancel(await select({
17
- message: 'Chọn project cần log work:',
18
- options
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
- return projects.find((project) => project.projectMemberId === projectMemberId);
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.map((task) => ({
28
- value: task.taskId,
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 async function chooseMessageSource(defaultMessage) {
38
- const source = handleCancel(await select({
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(input) {
53
- if (!input || input.trim() === '') {
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 async function askHours(defaultHours = CONFIG.defaultHours) {
64
- const value = handleCancel(await text({
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
- const numeric = Number(input);
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 async function confirmSubmit(payloadPreview) {
89
- const task = truncate(payloadPreview.taskName, 80);
90
- const description = payloadPreview.description
91
- ? ` | "${truncate(payloadPreview.description, 120)}"`
92
- : '';
93
- const ok = handleCancel(await confirm({
94
- message: `Log ${payloadPreview.hours}h to ${payloadPreview.projectName} with task "${task}"${description}?`,
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 showSuccess(message) {
106
- outro(message);
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.1.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
- "release:check": "npm test && npm run audit:prod && npm run pack:check && git diff --check",
62
- "test:windows-credential": "node --test --test-name-pattern=\"Windows Credential Manager stores, reads, and deletes a test credential\" test/credential-store.test.mjs",
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
  }
@@ -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 day's remaining RO hours across the groups. Treat its hours as a starting point, not a fact.
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 splits the day's total; use each day's `remainingByProject` to move hours to the project the work belongs to.
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.** Ticket prefixes and mappings pick the project. If a line stays unresolved, ask the user and offer `map_project` so it is automatic next time.
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