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
@@ -0,0 +1,285 @@
1
+ // Ranks the user's RO projects for a git repo that has no project mapping yet,
2
+ // so the user (or an agent) can confirm the likely project instead of typing a
3
+ // projectMemberId. Pure: every input is passed in.
4
+ //
5
+ // Evidence, strongest first:
6
+ // 1. ticket history: a ticket key from this repo's commits appears in one of
7
+ // the user's own RO entries (description or task) of the last 30 days;
8
+ // 2. name similarity: the repo folder name or git remote matches the project
9
+ // name, its acronym in parentheses, or the initials of its words;
10
+ // 3. single booked project: the repo's commit days had exactly one booked
11
+ // project.
12
+ import { extractTickets } from './commit-workflow.mjs';
13
+ import { projectIdentityKey, sameProjectIdentity } from './project-identity.mjs';
14
+
15
+ export const MAX_REPO_SUGGESTIONS = 5;
16
+
17
+ const SCORE_TICKET = 60;
18
+ const SCORE_TICKET_EXTRA = 5;
19
+ const SCORE_TICKET_MAX = 80;
20
+ const SCORE_ACRONYM = 30;
21
+ const SCORE_INITIALS = 25;
22
+ const SCORE_TOKEN = 15;
23
+ const SCORE_TOKEN_EXTRA = 5;
24
+ const SCORE_TOKEN_MAX = 28;
25
+ const SCORE_SINGLE_BOOKED = 15;
26
+ const MAX_TICKET_REASONS = 3;
27
+
28
+ // Words that say nothing about which project a repo belongs to.
29
+ export const NAME_STOP_WORDS = new Set([
30
+ 'app', 'apps', 'web', 'website', 'api', 'apis', 'fe', 'be', 'frontend', 'front', 'backend', 'back', 'end',
31
+ 'service', 'services', 'svc', 'mobile', 'the', 'a', 'an', 'of', 'and', 'for', 'project', 'projects', 'prj',
32
+ 'sit', 'uat', 'prod', 'dev', 'staging', 'stg', 'team', 'repo', 'git', 'src', 'main', 'client', 'server',
33
+ 'android', 'ios', 'ui', 'v1', 'v2', 'v3', 'new', 'old', 'core', 'common',
34
+ // Remote URL noise.
35
+ 'github', 'gitlab', 'bitbucket', 'com', 'org', 'net', 'io', 'www', 'http', 'https', 'ssh', 'scm', 'azure',
36
+ 'visualstudio', 'vs', 'tfs'
37
+ ]);
38
+
39
+ // Lowercase tokens of a name: split on -_./ (and any other non-alphanumeric
40
+ // character) and on camelCase / PascalCase boundaries.
41
+ export function tokenizeName(value = '') {
42
+ return String(value)
43
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
44
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
45
+ .split(/[^A-Za-z0-9]+/)
46
+ .map((token) => token.toLowerCase())
47
+ .filter(Boolean);
48
+ }
49
+
50
+ export function meaningfulTokens(value = '') {
51
+ return tokenizeName(value).filter((token) => token.length >= 2 && !NAME_STOP_WORDS.has(token) && !/^\d+$/.test(token));
52
+ }
53
+
54
+ // What a repo name can match in a project name: its meaningful tokens, the
55
+ // acronyms in parentheses ("GKE Fair Chem System (FCS)" -> fcs), and the
56
+ // initials of its words, with and without stop words ("gfcs").
57
+ export function projectNameKeys(projectName = '') {
58
+ const name = String(projectName);
59
+ const acronyms = [...name.matchAll(/\(([^)]+)\)/g)]
60
+ .flatMap((match) => tokenizeName(match[1]))
61
+ .filter((token) => token.length >= 2);
62
+ const words = tokenizeName(name.replace(/\([^)]*\)/g, ' ')).filter((token) => !/^\d+$/.test(token));
63
+ const initials = new Set();
64
+ for (const list of [words, words.filter((word) => !NAME_STOP_WORDS.has(word))]) {
65
+ if (list.length >= 2) {
66
+ initials.add(list.map((word) => word[0]).join(''));
67
+ }
68
+ }
69
+ return {
70
+ tokens: [...new Set(meaningfulTokens(name))],
71
+ acronyms: [...new Set(acronyms)],
72
+ initials: [...initials].filter((value) => value.length >= 2)
73
+ };
74
+ }
75
+
76
+ // The path part of a git remote ("git@host:org/repo.git",
77
+ // "https://host/org/repo.git", "ssh://git@host:22/org/repo") without the host
78
+ // and the .git suffix.
79
+ export function remoteRepoPath(remoteUrl = '') {
80
+ const text = String(remoteUrl || '').trim();
81
+ if (!text) {
82
+ return '';
83
+ }
84
+ let path = text;
85
+ const scheme = /^[a-z][a-z0-9+.-]*:\/\/[^/]+\/?(.*)$/i.exec(text);
86
+ const scp = /^[^@/\s]+@[^:/\s]+:(.*)$/.exec(text);
87
+ if (scheme) {
88
+ path = scheme[1];
89
+ } else if (scp) {
90
+ path = scp[1];
91
+ }
92
+ return path.replace(/\.git\/?$/i, '').replace(/^\/+|\/+$/g, '');
93
+ }
94
+
95
+ // RO entries from queryLogwork(includeEntries: true) as history items.
96
+ export function historyFromQueryResult(queryResult) {
97
+ return (Array.isArray(queryResult?.entries) ? queryResult.entries : [])
98
+ .map((entry) => ({
99
+ date: entry.date || '',
100
+ projectName: entry.projectName || '',
101
+ ...(hasValue(entry.projectMemberId) ? { projectMemberId: entry.projectMemberId } : {}),
102
+ ...(hasValue(entry.projectId) ? { projectId: entry.projectId } : {}),
103
+ text: [entry.taskName, entry.description].filter(Boolean).join(' ')
104
+ }))
105
+ .filter((entry) => entry.text && (entry.projectName || hasValue(entry.projectMemberId) || hasValue(entry.projectId)));
106
+ }
107
+
108
+ // Booked projects (bookedHours > 0) per date from a queryLogwork result.
109
+ export function bookedProjectsByDateFromQuery(queryResult) {
110
+ const byDate = {};
111
+ for (const day of Array.isArray(queryResult?.days) ? queryResult.days : []) {
112
+ byDate[day.date] = (day.projects || []).filter((project) => Number(project.bookedHours || 0) > 0);
113
+ }
114
+ return byDate;
115
+ }
116
+
117
+ // Every candidate with its score and reasons, highest score first; ties keep
118
+ // the candidates' order. A candidate without evidence scores 0.
119
+ export function rankRepoProjects({
120
+ repoPath = '',
121
+ repoGroups = [],
122
+ candidates = [],
123
+ history = [],
124
+ remoteUrl = '',
125
+ bookedProjectsByDate = {}
126
+ } = {}) {
127
+ const projects = uniqueProjects(candidates);
128
+ const tickets = [...new Set(repoGroups.flatMap((group) => group.tickets || []))];
129
+ const repoName = String(repoPath).split(/[\\/]/).filter(Boolean).pop() || '';
130
+ const nameSources = [
131
+ { label: 'repo name', tokens: meaningfulTokens(repoName) },
132
+ { label: 'git remote', tokens: meaningfulTokens(remoteRepoPath(remoteUrl)) }
133
+ ].filter((source) => source.tokens.length);
134
+ const singleBooked = singleBookedProject(repoGroups, bookedProjectsByDate);
135
+
136
+ const ranked = projects.map((project, index) => {
137
+ const reasons = [];
138
+ let score = 0;
139
+
140
+ const ticketHits = ticketEvidence(project, tickets, history);
141
+ if (ticketHits.length) {
142
+ score += Math.min(SCORE_TICKET_MAX, SCORE_TICKET + SCORE_TICKET_EXTRA * (ticketHits.length - 1));
143
+ reasons.push(...ticketHits.slice(0, MAX_TICKET_REASONS)
144
+ .map((hit) => `ticket ${hit.ticket} was logged to this project on ${hit.date}`));
145
+ }
146
+
147
+ const name = nameEvidence(project, nameSources);
148
+ if (name) {
149
+ score += name.score;
150
+ reasons.push(name.reason);
151
+ }
152
+
153
+ if (singleBooked && sameProjectIdentity(singleBooked.project, project)) {
154
+ score += SCORE_SINGLE_BOOKED;
155
+ reasons.push(`the only project booked on this repo's commit days (${formatDates(singleBooked.dates)})`);
156
+ }
157
+
158
+ return { ...projectRef(project), score, reasons, index };
159
+ });
160
+
161
+ return ranked
162
+ .sort((left, right) => right.score - left.score || left.index - right.index)
163
+ .map(({ index: _index, ...item }) => item);
164
+ }
165
+
166
+ // The top suggestions (score > 0), at most MAX_REPO_SUGGESTIONS.
167
+ export function suggestRepoProjects(input = {}) {
168
+ return rankRepoProjects(input)
169
+ .filter((item) => item.score > 0)
170
+ .slice(0, MAX_REPO_SUGGESTIONS);
171
+ }
172
+
173
+ // Picker order: suggestions first (top one first), then every other candidate
174
+ // in its original order. Each item: { project, suggestion | null }.
175
+ export function orderProjectsForPicker(candidates = [], suggestions = []) {
176
+ const items = suggestions.map((suggestion) => ({ project: projectRef(suggestion), suggestion }));
177
+ for (const project of uniqueProjects(candidates)) {
178
+ if (!items.some((item) => sameProjectIdentity(item.project, project))) {
179
+ items.push({ project: projectRef(project), suggestion: null });
180
+ }
181
+ }
182
+ return items;
183
+ }
184
+
185
+ export function formatSuggestionReason(suggestion) {
186
+ return (suggestion?.reasons || []).join('; ');
187
+ }
188
+
189
+ function ticketEvidence(project, tickets, history) {
190
+ if (!tickets.length || !history.length) {
191
+ return [];
192
+ }
193
+ const hits = [];
194
+ for (const ticket of tickets) {
195
+ const dates = history
196
+ .filter((entry) => sameProjectIdentity(entry, project) && extractTickets(entry.text).includes(ticket))
197
+ .map((entry) => entry.date)
198
+ .filter(Boolean)
199
+ .sort();
200
+ if (dates.length) {
201
+ hits.push({ ticket, date: dates.at(-1) });
202
+ }
203
+ }
204
+ return hits.sort((left, right) => right.date.localeCompare(left.date));
205
+ }
206
+
207
+ function nameEvidence(project, sources) {
208
+ if (!sources.length) {
209
+ return null;
210
+ }
211
+ const keys = projectNameKeys(project.projectName);
212
+ let best = null;
213
+ const consider = (candidate) => {
214
+ if (!best || candidate.score > best.score) {
215
+ best = candidate;
216
+ }
217
+ };
218
+ for (const source of sources) {
219
+ const acronym = keys.acronyms.find((value) => source.tokens.includes(value));
220
+ if (acronym) {
221
+ consider({ score: SCORE_ACRONYM, reason: `${source.label} matches ${acronym.toUpperCase()}` });
222
+ }
223
+ const initials = keys.initials.find((value) => source.tokens.includes(value));
224
+ if (initials) {
225
+ consider({ score: SCORE_INITIALS, reason: `${source.label} matches the initials ${initials.toUpperCase()} of the project name` });
226
+ }
227
+ const shared = [...new Set(source.tokens.filter((token) => keys.tokens.includes(token)))];
228
+ if (shared.length) {
229
+ consider({
230
+ score: Math.min(SCORE_TOKEN_MAX, SCORE_TOKEN + SCORE_TOKEN_EXTRA * (shared.length - 1)),
231
+ reason: `${source.label} shares ${shared.map((token) => `"${token}"`).join(', ')} with the project name`
232
+ });
233
+ }
234
+ }
235
+ return best;
236
+ }
237
+
238
+ function singleBookedProject(repoGroups, bookedProjectsByDate) {
239
+ const dates = [...new Set(repoGroups.map((group) => group.date).filter(Boolean))].sort();
240
+ if (!dates.length) {
241
+ return null;
242
+ }
243
+ const projects = [];
244
+ for (const date of dates) {
245
+ for (const project of bookedProjectsByDate?.[date] || []) {
246
+ if (!projects.some((known) => sameProjectIdentity(known, project))) {
247
+ projects.push(project);
248
+ }
249
+ }
250
+ }
251
+ return projects.length === 1 ? { project: projects[0], dates } : null;
252
+ }
253
+
254
+ function uniqueProjects(projects) {
255
+ const seen = new Set();
256
+ const results = [];
257
+ for (const project of Array.isArray(projects) ? projects : []) {
258
+ if (!project || !(project.projectName || hasValue(project.projectMemberId) || hasValue(project.projectId))) {
259
+ continue;
260
+ }
261
+ const key = projectIdentityKey(project);
262
+ if (seen.has(key) || results.some((known) => sameProjectIdentity(known, project))) {
263
+ continue;
264
+ }
265
+ seen.add(key);
266
+ results.push(project);
267
+ }
268
+ return results;
269
+ }
270
+
271
+ function projectRef(project = {}) {
272
+ return {
273
+ projectName: project.projectName || '',
274
+ ...(hasValue(project.projectMemberId) ? { projectMemberId: project.projectMemberId } : {}),
275
+ ...(hasValue(project.projectId) ? { projectId: project.projectId } : {})
276
+ };
277
+ }
278
+
279
+ function formatDates(dates) {
280
+ return dates.length > 3 ? `${dates.slice(0, 3).join(', ')} +${dates.length - 3} more` : dates.join(', ');
281
+ }
282
+
283
+ function hasValue(value) {
284
+ return value !== undefined && value !== null && String(value).trim() !== '';
285
+ }
@@ -0,0 +1,109 @@
1
+ // Normalization diagnostics attached (non-enumerable) to normalized RO record lists.
2
+
3
+ const NORMALIZATION_DIAGNOSTICS_KEY = 'normalization';
4
+
5
+ const MAX_DIAGNOSTIC_ITEMS = 25;
6
+
7
+ export function getNormalizationDiagnostics(value) {
8
+ return value?.[NORMALIZATION_DIAGNOSTICS_KEY] || emptyNormalizationDiagnostics();
9
+ }
10
+
11
+ export function createNormalizationDiagnostics(sourceShape = 'unknown') {
12
+ return {
13
+ status: 'ok',
14
+ sourceShape,
15
+ rowsRead: 0,
16
+ rowsAccepted: 0,
17
+ droppedRowCount: 0,
18
+ warningCount: 0,
19
+ warnings: [],
20
+ droppedRows: []
21
+ };
22
+ }
23
+
24
+ function emptyNormalizationDiagnostics() {
25
+ return createNormalizationDiagnostics('unknown');
26
+ }
27
+
28
+ export function attachNormalizationDiagnostics(value, diagnostics) {
29
+ Object.defineProperty(value, NORMALIZATION_DIAGNOSTICS_KEY, {
30
+ value: finalizeNormalizationDiagnostics(diagnostics),
31
+ enumerable: false,
32
+ configurable: true
33
+ });
34
+ return value;
35
+ }
36
+
37
+ function finalizeNormalizationDiagnostics(diagnostics) {
38
+ const warningCount = diagnostics.warnings.length;
39
+ const droppedRowCount = diagnostics.droppedRows.length;
40
+ return {
41
+ ...diagnostics,
42
+ status: warningCount || droppedRowCount ? 'warning' : 'ok',
43
+ warningCount,
44
+ droppedRowCount
45
+ };
46
+ }
47
+
48
+ export function combineNormalizationDiagnostics(...items) {
49
+ const diagnostics = createNormalizationDiagnostics('combined');
50
+ for (const item of items) {
51
+ const current = item?.warnings || item?.droppedRows
52
+ ? item
53
+ : getNormalizationDiagnostics(item);
54
+ if (!current) {
55
+ continue;
56
+ }
57
+ diagnostics.rowsRead += Number(current.rowsRead || 0);
58
+ diagnostics.rowsAccepted += Number(current.rowsAccepted || 0);
59
+ for (const warning of current.warnings || []) {
60
+ pushDiagnostic(diagnostics.warnings, warning);
61
+ }
62
+ for (const dropped of current.droppedRows || []) {
63
+ pushDiagnostic(diagnostics.droppedRows, dropped);
64
+ }
65
+ }
66
+ return finalizeNormalizationDiagnostics(diagnostics);
67
+ }
68
+
69
+ export function addWarning(diagnostics, reason, details = {}) {
70
+ if (!diagnostics) {
71
+ return;
72
+ }
73
+ pushDiagnostic(diagnostics.warnings, compactDiagnostic({
74
+ reason,
75
+ ...details
76
+ }));
77
+ }
78
+
79
+ export function addDroppedRow(diagnostics, reason, details = {}) {
80
+ if (!diagnostics) {
81
+ return;
82
+ }
83
+ pushDiagnostic(diagnostics.droppedRows, compactDiagnostic({
84
+ reason,
85
+ ...details
86
+ }));
87
+ }
88
+
89
+ function pushDiagnostic(items, item) {
90
+ if (items.length < MAX_DIAGNOSTIC_ITEMS) {
91
+ items.push(item);
92
+ }
93
+ }
94
+
95
+ function compactDiagnostic(details) {
96
+ return Object.fromEntries(Object.entries(details).filter(([, value]) => (
97
+ value !== undefined &&
98
+ value !== null &&
99
+ value !== ''
100
+ )));
101
+ }
102
+
103
+ export function diagnosticProjectFields({ projectMemberId, projectId, projectName } = {}) {
104
+ return compactDiagnostic({
105
+ projectMemberId,
106
+ projectId,
107
+ projectName
108
+ });
109
+ }