logwork-cli 0.0.0-stage → 1.0.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 (91) hide show
  1. package/LICENSE +11 -0
  2. package/README.md +287 -2
  3. package/RELEASE.md +89 -0
  4. package/bin/logwork.mjs +101 -0
  5. package/commands/auth.mjs +227 -0
  6. package/commands/delete.mjs +69 -0
  7. package/commands/doctor.mjs +24 -0
  8. package/commands/edit.mjs +88 -0
  9. package/commands/history.mjs +62 -0
  10. package/commands/query.mjs +52 -0
  11. package/commands/quick.mjs +163 -0
  12. package/commands/reconcile.mjs +55 -0
  13. package/commands/release.mjs +77 -0
  14. package/commands/reminder.mjs +172 -0
  15. package/commands/setup.mjs +504 -0
  16. package/commands/tui.mjs +50 -0
  17. package/commands/update.mjs +161 -0
  18. package/docs/advanced.md +334 -0
  19. package/docs/mcp-setup.md +347 -0
  20. package/docs/operations-guide.md +587 -0
  21. package/docs/recipes.md +259 -0
  22. package/docs/security.md +151 -0
  23. package/examples/mcp/antigravity.mcp_config.json +8 -0
  24. package/examples/mcp/codex.config.toml +5 -0
  25. package/examples/mcp/copilot.mcp.json +9 -0
  26. package/lib/api-auth.mjs +1259 -0
  27. package/lib/api.mjs +1422 -0
  28. package/lib/apply-ledger.mjs +227 -0
  29. package/lib/atomic-file.mjs +24 -0
  30. package/lib/auth-diagnostics.mjs +113 -0
  31. package/lib/auth-errors.mjs +22 -0
  32. package/lib/auth-redaction.mjs +78 -0
  33. package/lib/auth-terminal.mjs +97 -0
  34. package/lib/auth.mjs +323 -0
  35. package/lib/batch-parser.mjs +145 -0
  36. package/lib/batch-workflow.mjs +842 -0
  37. package/lib/config.mjs +191 -0
  38. package/lib/credential-store.mjs +130 -0
  39. package/lib/diagnostics.mjs +307 -0
  40. package/lib/entrypoint.mjs +14 -0
  41. package/lib/file-lock.mjs +44 -0
  42. package/lib/http.mjs +125 -0
  43. package/lib/jira-api.mjs +258 -0
  44. package/lib/jira-auth.mjs +203 -0
  45. package/lib/jira-workflow.mjs +523 -0
  46. package/lib/keychain.mjs +105 -0
  47. package/lib/legacy-migration.mjs +66 -0
  48. package/lib/list-navigation.mjs +19 -0
  49. package/lib/logwork-config.mjs +199 -0
  50. package/lib/logwork-f03-enrich.mjs +41 -0
  51. package/lib/macos.mjs +33 -0
  52. package/lib/manual-apply-state.mjs +35 -0
  53. package/lib/manual-commands.mjs +294 -0
  54. package/lib/manual-drafts.mjs +154 -0
  55. package/lib/manual-ink-app.mjs +1654 -0
  56. package/lib/manual-ink-auth.mjs +349 -0
  57. package/lib/manual-ink-ui.mjs +675 -0
  58. package/lib/manual-input.mjs +21 -0
  59. package/lib/manual-logwork-wizard.mjs +310 -0
  60. package/lib/manual-mcp.mjs +187 -0
  61. package/lib/manual-repl.mjs +1037 -0
  62. package/lib/manual-targets.mjs +78 -0
  63. package/lib/mcp-server.mjs +793 -0
  64. package/lib/os-notification.mjs +105 -0
  65. package/lib/package-info.mjs +17 -0
  66. package/lib/paths.mjs +68 -0
  67. package/lib/project-identity.mjs +66 -0
  68. package/lib/project-mapping-workflow.mjs +309 -0
  69. package/lib/project-resolver.mjs +373 -0
  70. package/lib/query-workflow.mjs +553 -0
  71. package/lib/reconciliation-workflow.mjs +446 -0
  72. package/lib/release-doctor.mjs +448 -0
  73. package/lib/reminder-config.mjs +71 -0
  74. package/lib/reminder-scheduler.mjs +301 -0
  75. package/lib/reminder-service.mjs +118 -0
  76. package/lib/reminder-state.mjs +38 -0
  77. package/lib/reminder-workflow.mjs +299 -0
  78. package/lib/ro-delete-args.mjs +38 -0
  79. package/lib/ro-delete-workflow.mjs +115 -0
  80. package/lib/ro-edit-args.mjs +142 -0
  81. package/lib/ro-edit-workflow.mjs +632 -0
  82. package/lib/ro-entry-reader.mjs +56 -0
  83. package/lib/ro-resubmit-workflow.mjs +134 -0
  84. package/lib/terminal-auth-prompts.mjs +120 -0
  85. package/lib/ui.mjs +133 -0
  86. package/lib/update-service.mjs +350 -0
  87. package/lib/util.mjs +66 -0
  88. package/lib/windows-credential-manager.mjs +260 -0
  89. package/lib/worklog-task-resolver.mjs +113 -0
  90. package/package.json +72 -4
  91. package/setup.sh +22 -0
@@ -0,0 +1,373 @@
1
+ import {
2
+ projectIdentityKey,
3
+ projectMatchesMapping
4
+ } from './project-identity.mjs';
5
+
6
+ const RESOLVE_THRESHOLD = 0.75;
7
+ const AMBIGUOUS_MARGIN = 0.1;
8
+ const CONFIG_TICKET_CONFIDENCE = 0.99;
9
+ const CONFIG_KEYWORD_CONFIDENCE = 0.98;
10
+ const PROJECT_NAME_CONFIDENCE = 0.95;
11
+ const PROJECT_NAME_TOKEN_CONFIDENCE = 0.65;
12
+ const SINGLE_BOOKED_CONFIDENCE = 0.95;
13
+
14
+ export function resolveEntryProject({
15
+ entry,
16
+ projects,
17
+ membershipProjects = [],
18
+ projectOverrides = {},
19
+ config = {}
20
+ }) {
21
+ const bookedProjects = Array.isArray(projects) ? projects : [];
22
+ const memberships = Array.isArray(membershipProjects) ? membershipProjects : [];
23
+ const override = projectOverrides?.[entry.id];
24
+
25
+ if (override !== undefined && override !== null && String(override).trim() !== '') {
26
+ const project = findProjectByMemberId(bookedProjects, override);
27
+
28
+ if (project) {
29
+ return resolved(project, 1, 'override', [], true);
30
+ }
31
+
32
+ const membership = findProjectByMemberId(memberships, override);
33
+ if (membership) {
34
+ return resolved(membership, 1, 'override_unbooked', [], false);
35
+ }
36
+
37
+ return unresolved('override_not_found', combineCandidates(bookedProjects, memberships));
38
+ }
39
+
40
+ const membershipCandidates = memberships.filter((membership) => (
41
+ !bookedProjects.some((booked) => String(booked.projectMemberId) === String(membership.projectMemberId))
42
+ ));
43
+
44
+ if (bookedProjects.length === 1) {
45
+ const bookedProject = bookedProjects[0];
46
+ const bookedScore = scoreProject(entry, bookedProject, config);
47
+
48
+ if (isConfigSource(bookedScore.source)) {
49
+ return resolved(bookedProject, bookedScore.confidence, bookedScore.source, [bookedScore], true);
50
+ }
51
+
52
+ const conflicts = findConflictingMappingCandidates({
53
+ entry,
54
+ bookedProject,
55
+ membershipCandidates,
56
+ config
57
+ });
58
+ if (conflicts.length) {
59
+ return unresolvedWithCandidates(
60
+ 'single_booked_project_conflicts_with_mapping',
61
+ [
62
+ ...conflicts,
63
+ toCandidate({
64
+ project: bookedProject,
65
+ confidence: SINGLE_BOOKED_CONFIDENCE,
66
+ source: 'single_booked_project'
67
+ }, true)
68
+ ],
69
+ conflicts[0].confidence
70
+ );
71
+ }
72
+
73
+ return resolved(bookedProject, SINGLE_BOOKED_CONFIDENCE, 'single_booked_project', [], true);
74
+ }
75
+
76
+ if (bookedProjects.length > 1) {
77
+ const bookedResult = resolveScoredProjects(entry, bookedProjects, config, true);
78
+ if (bookedResult.status === 'resolved') {
79
+ return bookedResult;
80
+ }
81
+ }
82
+
83
+ const unbookedResult = resolveScoredProjects(entry, membershipCandidates, config, false);
84
+ if (unbookedResult.status === 'resolved_unbooked') {
85
+ return unbookedResult;
86
+ }
87
+
88
+ if (bookedProjects.length === 0) {
89
+ return unresolved('no_booked_projects', membershipCandidates);
90
+ }
91
+
92
+ const scored = bookedProjects
93
+ .map((project) => scoreProject(entry, project, config))
94
+ .sort((left, right) => right.confidence - left.confidence);
95
+ const best = scored[0];
96
+
97
+ return {
98
+ status: 'unresolved',
99
+ reason: best.confidence > 0 ? 'ambiguous_project_match' : 'no_project_match',
100
+ confidence: best.confidence,
101
+ candidates: scored.map((candidate) => toCandidate(candidate, true))
102
+ };
103
+ }
104
+
105
+ function resolveScoredProjects(entry, projects, config, booked) {
106
+ if (!projects.length) {
107
+ return unresolved(booked ? 'no_booked_projects' : 'no_membership_project_match', []);
108
+ }
109
+
110
+ const scored = projects
111
+ .map((project) => scoreProject(entry, project, config, { allowProjectTextMatch: booked }))
112
+ .sort((left, right) => right.confidence - left.confidence);
113
+ const best = scored[0];
114
+ const second = scored[1];
115
+
116
+ if (
117
+ best.confidence >= RESOLVE_THRESHOLD &&
118
+ isClearWinner(best, second)
119
+ ) {
120
+ return resolved(best.project, best.confidence, booked ? best.source : `${best.source}_unbooked`, scored, booked);
121
+ }
122
+
123
+ return {
124
+ status: 'unresolved',
125
+ reason: best.confidence > 0 ? 'ambiguous_project_match' : 'no_project_match',
126
+ confidence: best.confidence,
127
+ candidates: scored.map((candidate) => toCandidate(candidate, booked))
128
+ };
129
+ }
130
+
131
+ function scoreProject(entry, project, config, { allowProjectTextMatch = true } = {}) {
132
+ const taskText = normalize(entry.text ?? entry.taskName);
133
+ const projectName = normalize(project.projectName);
134
+ let confidence = 0;
135
+ let source = 'none';
136
+
137
+ if (allowProjectTextMatch && projectName && taskText.includes(projectName)) {
138
+ confidence = PROJECT_NAME_CONFIDENCE;
139
+ source = 'project_name';
140
+ }
141
+
142
+ if (allowProjectTextMatch) {
143
+ for (const word of projectName.split(/\s+/).filter((part) => part.length >= 4)) {
144
+ if (taskText.includes(word) && confidence < PROJECT_NAME_TOKEN_CONFIDENCE) {
145
+ confidence = PROJECT_NAME_TOKEN_CONFIDENCE;
146
+ source = 'project_name_token';
147
+ }
148
+ }
149
+ }
150
+
151
+ for (const mapping of config.projectMappings || []) {
152
+ if (!mappingMatchesProject(mapping, project)) {
153
+ continue;
154
+ }
155
+
156
+ const mappingMatch = matchEntryMapping(entry, mapping);
157
+ if (mappingMatch && confidence < mappingMatch.confidence) {
158
+ confidence = mappingMatch.confidence;
159
+ source = mappingMatch.source;
160
+ }
161
+ }
162
+
163
+ return { project, confidence, source };
164
+ }
165
+
166
+ function mappingMatchesProject(mapping, project) {
167
+ return projectMatchesMapping(project, mapping);
168
+ }
169
+
170
+ function findConflictingMappingCandidates({
171
+ entry,
172
+ bookedProject,
173
+ membershipCandidates,
174
+ config
175
+ }) {
176
+ const candidates = new Map();
177
+
178
+ for (const mapping of config.projectMappings || []) {
179
+ const mappingMatch = matchEntryMapping(entry, mapping);
180
+ if (!mappingMatch || mappingMatchesProject(mapping, bookedProject)) {
181
+ continue;
182
+ }
183
+
184
+ const matchedMemberships = membershipCandidates.filter((membership) => mappingMatchesProject(mapping, membership));
185
+ if (matchedMemberships.length) {
186
+ for (const membership of matchedMemberships) {
187
+ addCandidate(candidates, {
188
+ project: membership,
189
+ booked: false,
190
+ confidence: mappingMatch.confidence,
191
+ source: mappingMatch.source
192
+ });
193
+ }
194
+ continue;
195
+ }
196
+
197
+ const mappedProject = projectFromMapping(mapping);
198
+ if (mappedProject) {
199
+ addCandidate(candidates, {
200
+ project: mappedProject,
201
+ booked: false,
202
+ confidence: mappingMatch.confidence,
203
+ source: mappingMatch.source
204
+ });
205
+ }
206
+ }
207
+
208
+ return [...candidates.values()]
209
+ .map((candidate) => toCandidate(candidate, candidate.booked))
210
+ .sort(compareCandidates);
211
+ }
212
+
213
+ function matchEntryMapping(entry, mapping) {
214
+ const ticketMatch = (entry.tickets || []).some((ticket) => (
215
+ (mapping.tickets || []).some((prefix) => {
216
+ const normalizedPrefix = String(prefix || '').trim().toUpperCase();
217
+ return normalizedPrefix && ticket.startsWith(normalizedPrefix);
218
+ })
219
+ ));
220
+ if (ticketMatch) {
221
+ return {
222
+ confidence: CONFIG_TICKET_CONFIDENCE,
223
+ source: 'config_ticket'
224
+ };
225
+ }
226
+
227
+ const taskText = normalize(entry.text ?? entry.taskName);
228
+ const keywordMatch = (mapping.keywords || []).some((keyword) => {
229
+ const normalizedKeyword = normalize(keyword);
230
+ return normalizedKeyword && taskText.includes(normalizedKeyword);
231
+ });
232
+ if (keywordMatch) {
233
+ return {
234
+ confidence: CONFIG_KEYWORD_CONFIDENCE,
235
+ source: 'config_keyword'
236
+ };
237
+ }
238
+
239
+ return null;
240
+ }
241
+
242
+ function projectFromMapping(mapping) {
243
+ const project = {
244
+ projectMemberId: mapping.projectMemberId,
245
+ projectId: mapping.projectId,
246
+ projectName: mapping.projectName
247
+ };
248
+
249
+ const hasProjectData = project.projectMemberId !== undefined ||
250
+ project.projectId !== undefined ||
251
+ Boolean(normalize(project.projectName));
252
+
253
+ return hasProjectData
254
+ ? project
255
+ : null;
256
+ }
257
+
258
+ function addCandidate(candidates, candidate) {
259
+ const key = candidateKey(candidate.project);
260
+ const existing = candidates.get(key);
261
+ if (!existing || candidate.confidence > existing.confidence) {
262
+ candidates.set(key, candidate);
263
+ }
264
+ }
265
+
266
+ function unresolvedWithCandidates(reason, candidates, confidence = 0) {
267
+ return {
268
+ status: 'unresolved',
269
+ reason,
270
+ confidence,
271
+ candidates
272
+ };
273
+ }
274
+
275
+ function isClearWinner(best, second) {
276
+ if (!second) {
277
+ return true;
278
+ }
279
+
280
+ if (isConfigSource(best.source) && !isConfigSource(second.source)) {
281
+ return true;
282
+ }
283
+
284
+ return best.confidence - second.confidence >= AMBIGUOUS_MARGIN;
285
+ }
286
+
287
+ function isConfigSource(source) {
288
+ return source === 'config_ticket' || source === 'config_keyword';
289
+ }
290
+
291
+ function resolved(project, confidence, source, candidates, booked) {
292
+ return {
293
+ status: booked ? 'resolved' : 'resolved_unbooked',
294
+ reason: source,
295
+ confidence,
296
+ booked,
297
+ requiresAllowUnbooked: !booked,
298
+ project,
299
+ candidates: candidates.map((candidate) => toCandidate(candidate, booked))
300
+ };
301
+ }
302
+
303
+ function unresolved(reason, projects) {
304
+ return {
305
+ status: 'unresolved',
306
+ reason,
307
+ confidence: 0,
308
+ candidates: projects.map((project) => ({
309
+ projectMemberId: project.projectMemberId,
310
+ projectId: project.projectId,
311
+ projectName: project.projectName,
312
+ booked: Boolean(project.booked),
313
+ confidence: 0,
314
+ source: 'none'
315
+ }))
316
+ };
317
+ }
318
+
319
+ function combineCandidates(bookedProjects, membershipProjects) {
320
+ const candidates = new Map();
321
+
322
+ for (const project of bookedProjects) {
323
+ candidates.set(candidateKey(project), {
324
+ ...project,
325
+ booked: true
326
+ });
327
+ }
328
+
329
+ for (const project of membershipProjects) {
330
+ const key = candidateKey(project);
331
+ if (candidates.has(key)) {
332
+ continue;
333
+ }
334
+
335
+ candidates.set(key, {
336
+ ...project,
337
+ booked: false
338
+ });
339
+ }
340
+
341
+ return [...candidates.values()];
342
+ }
343
+
344
+ function toCandidate(item, booked = item.booked) {
345
+ const project = item.project || item;
346
+ return {
347
+ projectMemberId: project.projectMemberId,
348
+ projectId: project.projectId,
349
+ projectName: project.projectName,
350
+ booked: Boolean(booked),
351
+ confidence: item.confidence ?? 0,
352
+ source: item.source || 'none'
353
+ };
354
+ }
355
+
356
+ function compareCandidates(left, right) {
357
+ return right.confidence - left.confidence ||
358
+ String(left.projectName || '').localeCompare(String(right.projectName || ''));
359
+ }
360
+
361
+ function findProjectByMemberId(projects, projectMemberId) {
362
+ return projects.find((candidate) => (
363
+ String(candidate.projectMemberId) === String(projectMemberId)
364
+ ));
365
+ }
366
+
367
+ function candidateKey(project) {
368
+ return projectIdentityKey(project);
369
+ }
370
+
371
+ function normalize(value) {
372
+ return String(value || '').toLowerCase().replace(/\s+/g, ' ').trim();
373
+ }