task-summary-extractor 8.1.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.
@@ -0,0 +1,451 @@
1
+ /**
2
+ * Progress Updater — AI-powered progress assessment using change detection data.
3
+ *
4
+ * Two assessment modes:
5
+ * 1. Local (deterministic) — works without Gemini, uses correlation scores
6
+ * 2. AI-enhanced — sends items + changes to Gemini for smart assessment
7
+ *
8
+ * Also provides:
9
+ * - Markdown rendering of progress reports
10
+ * - Merge helpers to annotate analysis items with progress data
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const { extractJson } = require('./json-parser');
16
+ const config = require('../config');
17
+ // Access config.GEMINI_MODEL at call time for runtime model changes.
18
+
19
+ // ======================== STATUS CONSTANTS ========================
20
+
21
+ const STATUS = {
22
+ DONE: 'DONE',
23
+ IN_PROGRESS: 'IN_PROGRESS',
24
+ NOT_STARTED: 'NOT_STARTED',
25
+ SUPERSEDED: 'SUPERSEDED',
26
+ };
27
+
28
+ const STATUS_ICONS = {
29
+ DONE: '✅',
30
+ IN_PROGRESS: '🔄',
31
+ NOT_STARTED: '⏳',
32
+ SUPERSEDED: '🔀',
33
+ };
34
+
35
+ // ======================== LOCAL ASSESSMENT ========================
36
+
37
+ /**
38
+ * Deterministic progress assessment based purely on correlation scores.
39
+ * No API calls — works offline.
40
+ *
41
+ * @param {Array} items - From extractTrackableItems()
42
+ * @param {Map} correlations - From correlateItemsWithChanges()
43
+ * @returns {Array<{item_id, item_type, status, confidence, evidence[], notes}>}
44
+ */
45
+ function assessProgressLocal(items, correlations) {
46
+ return items.map(item => {
47
+ const corr = correlations.get(item.id);
48
+
49
+ if (!corr || corr.score === 0) {
50
+ return {
51
+ item_id: item.id,
52
+ item_type: item.type,
53
+ title: item.title,
54
+ status: STATUS.NOT_STARTED,
55
+ confidence: 'LOW',
56
+ evidence: [],
57
+ notes: 'No matching changes detected in git history or documents.',
58
+ };
59
+ }
60
+
61
+ return {
62
+ item_id: item.id,
63
+ item_type: item.type,
64
+ title: item.title,
65
+ status: corr.localAssessment,
66
+ confidence: corr.localConfidence,
67
+ evidence: corr.evidence,
68
+ notes: `Local correlation score: ${corr.score.toFixed(2)}`,
69
+ };
70
+ });
71
+ }
72
+
73
+ // ======================== AI ASSESSMENT ========================
74
+
75
+ /**
76
+ * Build a focused Gemini prompt for progress assessment.
77
+ *
78
+ * @param {Array} items - Trackable items
79
+ * @param {object} changeReport - From detectAllChanges()
80
+ * @param {Array} localAssessments - From assessProgressLocal()
81
+ * @returns {string}
82
+ */
83
+ function buildProgressPrompt(items, changeReport, localAssessments) {
84
+ const lines = [];
85
+
86
+ lines.push('# Progress Assessment Task');
87
+ lines.push('');
88
+ lines.push('You are analyzing whether work items from a meeting/call have been completed.');
89
+ lines.push('Based on the git changes and document updates below, assess each item\'s progress.');
90
+ lines.push('');
91
+
92
+ // Items section
93
+ lines.push('## Items To Assess');
94
+ lines.push('');
95
+ for (const item of items) {
96
+ lines.push(`### [${item.type}] ${item.id}: ${item.title}`);
97
+ if (item.description && item.description !== item.title) {
98
+ lines.push(` Description: ${item.description}`);
99
+ }
100
+ if (item.fileRefs.length > 0) {
101
+ lines.push(` Referenced files: ${item.fileRefs.join(', ')}`);
102
+ }
103
+ if (item.assignee) {
104
+ lines.push(` Assigned to: ${item.assignee}`);
105
+ }
106
+ lines.push('');
107
+ }
108
+
109
+ // Git changes section
110
+ if (changeReport.git.available) {
111
+ lines.push('## Git Changes Since Last Analysis');
112
+ lines.push(`Branch: ${changeReport.git.branch || 'unknown'}`);
113
+ lines.push(`Commits: ${changeReport.git.commits.length}`);
114
+ lines.push(`Files changed: ${changeReport.git.changedFiles.length}`);
115
+ lines.push('');
116
+
117
+ if (changeReport.git.commits.length > 0) {
118
+ lines.push('### Recent Commits');
119
+ for (const c of changeReport.git.commits.slice(0, 50)) {
120
+ lines.push(`- **${c.hash}** (${c.date}): ${c.message}`);
121
+ if (c.files && c.files.length > 0) {
122
+ for (const f of c.files.slice(0, 10)) {
123
+ lines.push(` - ${f}`);
124
+ }
125
+ if (c.files.length > 10) lines.push(` - ... and ${c.files.length - 10} more`);
126
+ }
127
+ }
128
+ lines.push('');
129
+ }
130
+
131
+ if (changeReport.git.changedFiles.length > 0) {
132
+ lines.push('### Changed Files');
133
+ for (const f of changeReport.git.changedFiles.slice(0, 100)) {
134
+ lines.push(`- [${f.status}] ${f.path} (${f.changes} change(s))`);
135
+ }
136
+ lines.push('');
137
+ }
138
+ }
139
+
140
+ // Document changes
141
+ if (changeReport.documents.changes.length > 0) {
142
+ lines.push('## Document Updates (in call folder)');
143
+ for (const d of changeReport.documents.changes) {
144
+ lines.push(`- ${d.relPath} (modified: ${d.modified})`);
145
+ }
146
+ lines.push('');
147
+ }
148
+
149
+ // Local correlations as hints
150
+ lines.push('## Pre-Computed Correlations (hints — verify and improve)');
151
+ for (const la of localAssessments) {
152
+ lines.push(`- ${la.item_id} (${la.item_type}): local says ${la.status} (${la.confidence})`);
153
+ if (la.evidence.length > 0) {
154
+ for (const e of la.evidence.slice(0, 5)) {
155
+ lines.push(` • [${e.type}] ${e.detail}`);
156
+ }
157
+ }
158
+ }
159
+ lines.push('');
160
+
161
+ // Instructions
162
+ lines.push('## Instructions');
163
+ lines.push('');
164
+ lines.push('For EACH item, provide a JSON response with this structure:');
165
+ lines.push('```json');
166
+ lines.push('{');
167
+ lines.push(' "assessments": [');
168
+ lines.push(' {');
169
+ lines.push(' "item_id": "string — the exact item ID",');
170
+ lines.push(' "item_type": "string — ticket/change_request/action_item/blocker/scope_change",');
171
+ lines.push(' "status": "DONE | IN_PROGRESS | NOT_STARTED | SUPERSEDED",');
172
+ lines.push(' "confidence": "HIGH | MEDIUM | LOW",');
173
+ lines.push(' "evidence": ["array of specific evidence strings"],');
174
+ lines.push(' "notes": "string — brief explanation of your assessment"');
175
+ lines.push(' }');
176
+ lines.push(' ],');
177
+ lines.push(' "overall_summary": "Brief summary of overall project progress",');
178
+ lines.push(' "recommendations": ["Array of actionable recommendations"]');
179
+ lines.push('}');
180
+ lines.push('```');
181
+ lines.push('');
182
+ lines.push('Assessment rules:');
183
+ lines.push('- DONE = Clear evidence that the item\'s requirements are fully addressed');
184
+ lines.push('- IN_PROGRESS = Partial changes exist that relate to this item');
185
+ lines.push('- NOT_STARTED = No relevant changes found');
186
+ lines.push('- SUPERSEDED = Item is no longer relevant (replaced/cancelled by later changes)');
187
+ lines.push('- Be conservative — prefer IN_PROGRESS over DONE unless evidence is strong');
188
+ lines.push('- Use commit messages, file changes, and file names as evidence');
189
+ lines.push('- If a commit message explicitly references an item ID, that\'s strong evidence');
190
+
191
+ return lines.join('\n');
192
+ }
193
+
194
+ /**
195
+ * Run AI-powered progress assessment via Gemini.
196
+ *
197
+ * @param {object} ai - Initialized Gemini AI instance
198
+ * @param {Array} items - Trackable items
199
+ * @param {object} changeReport - From detectAllChanges()
200
+ * @param {Array} localAssessments - From assessProgressLocal()
201
+ * @param {object} [opts] - { thinkingBudget }
202
+ * @returns {object} { assessments[], overall_summary, recommendations[], model, tokenUsage }
203
+ */
204
+ async function assessProgressWithAI(ai, items, changeReport, localAssessments, opts = {}) {
205
+ const prompt = buildProgressPrompt(items, changeReport, localAssessments);
206
+ const thinkingBudget = opts.thinkingBudget || 16384;
207
+
208
+ const result = await ai.models.generateContent({
209
+ model: config.GEMINI_MODEL,
210
+ contents: [{ role: 'user', parts: [{ text: prompt }] }],
211
+ config: {
212
+ temperature: 0,
213
+ thinkingConfig: { thinkingBudget },
214
+ },
215
+ });
216
+
217
+ const rawText = result.text || '';
218
+ const tokenUsage = result.usageMetadata || {};
219
+
220
+ const parsed = extractJson(rawText);
221
+ if (!parsed) {
222
+ throw new Error('Failed to parse AI progress assessment response as JSON');
223
+ }
224
+
225
+ return {
226
+ assessments: parsed.assessments || [],
227
+ overall_summary: parsed.overall_summary || 'No summary provided',
228
+ recommendations: parsed.recommendations || [],
229
+ model: config.GEMINI_MODEL,
230
+ tokenUsage,
231
+ };
232
+ }
233
+
234
+ // ======================== MERGE & SUMMARY ========================
235
+
236
+ /**
237
+ * Merge AI or local assessments back into the analysis items,
238
+ * adding a `_progress` annotation to each matched item.
239
+ *
240
+ * @param {object} analysis - The compiled analysis object
241
+ * @param {Array} assessments - Array of assessment objects
242
+ * @returns {object} The annotated analysis (mutated)
243
+ */
244
+ function mergeProgressIntoAnalysis(analysis, assessments) {
245
+ if (!analysis || !assessments) return analysis;
246
+
247
+ const assessMap = new Map();
248
+ for (const a of assessments) {
249
+ assessMap.set(a.item_id, a);
250
+ }
251
+
252
+ const sections = [
253
+ { key: 'tickets', idField: 'ticket_id' },
254
+ { key: 'change_requests', idField: 'id' },
255
+ { key: 'action_items', idField: 'id' },
256
+ { key: 'blockers', idField: 'id' },
257
+ { key: 'scope_changes', idField: 'id' },
258
+ ];
259
+
260
+ for (const { key, idField } of sections) {
261
+ for (const item of (analysis[key] || [])) {
262
+ const itemId = item[idField];
263
+ const assessment = assessMap.get(itemId);
264
+ if (assessment) {
265
+ item._progress = {
266
+ status: assessment.status,
267
+ confidence: assessment.confidence,
268
+ evidence: assessment.evidence,
269
+ notes: assessment.notes,
270
+ assessedAt: new Date().toISOString(),
271
+ };
272
+ }
273
+ }
274
+ }
275
+
276
+ return analysis;
277
+ }
278
+
279
+ /**
280
+ * Build summary counts from assessments.
281
+ *
282
+ * @param {Array} assessments
283
+ * @returns {object} { done, inProgress, notStarted, superseded, total }
284
+ */
285
+ function buildProgressSummary(assessments) {
286
+ const summary = { done: 0, inProgress: 0, notStarted: 0, superseded: 0, total: assessments.length };
287
+ for (const a of assessments) {
288
+ switch (a.status) {
289
+ case STATUS.DONE: summary.done++; break;
290
+ case STATUS.IN_PROGRESS: summary.inProgress++; break;
291
+ case STATUS.NOT_STARTED: summary.notStarted++; break;
292
+ case STATUS.SUPERSEDED: summary.superseded++; break;
293
+ }
294
+ }
295
+ return summary;
296
+ }
297
+
298
+ // ======================== MARKDOWN RENDERING ========================
299
+
300
+ /**
301
+ * Render a full progress report as Markdown.
302
+ *
303
+ * @param {object} params
304
+ * @param {Array} params.assessments - Per-item assessments
305
+ * @param {object} params.changeReport - From detectAllChanges()
306
+ * @param {string} [params.overallSummary] - AI-generated summary
307
+ * @param {string[]} [params.recommendations] - AI-generated recommendations
308
+ * @param {object} [params.meta] - { callName, timestamp, mode }
309
+ * @returns {string} Markdown content
310
+ */
311
+ function renderProgressMarkdown({ assessments, changeReport, overallSummary, recommendations, meta = {} }) {
312
+ const lines = [];
313
+ const summary = buildProgressSummary(assessments);
314
+ const ts = meta.timestamp || new Date().toISOString();
315
+
316
+ lines.push(`# Progress Report${meta.callName ? ` — ${meta.callName}` : ''}`);
317
+ lines.push('');
318
+ lines.push(`> Generated: ${ts}`);
319
+ lines.push(`> Mode: ${meta.mode || 'local'}`);
320
+ if (changeReport.git.available) {
321
+ lines.push(`> Branch: ${changeReport.git.branch || 'unknown'}`);
322
+ lines.push(`> Commits since last analysis: ${changeReport.totals.commits}`);
323
+ lines.push(`> Files changed: ${changeReport.totals.filesChanged}`);
324
+ }
325
+ lines.push(`> Documents updated: ${changeReport.totals.docsChanged}`);
326
+ lines.push('');
327
+
328
+ // Overview bar
329
+ lines.push('## Overview');
330
+ lines.push('');
331
+ lines.push(`| Status | Count |`);
332
+ lines.push(`|--------|-------|`);
333
+ lines.push(`| ${STATUS_ICONS.DONE} Completed | ${summary.done} |`);
334
+ lines.push(`| ${STATUS_ICONS.IN_PROGRESS} In Progress | ${summary.inProgress} |`);
335
+ lines.push(`| ${STATUS_ICONS.NOT_STARTED} Not Started | ${summary.notStarted} |`);
336
+ lines.push(`| ${STATUS_ICONS.SUPERSEDED} Superseded | ${summary.superseded} |`);
337
+ lines.push(`| **Total** | **${summary.total}** |`);
338
+ lines.push('');
339
+
340
+ // Progress percentage
341
+ const pct = summary.total > 0 ? ((summary.done / summary.total) * 100).toFixed(0) : 0;
342
+ lines.push(`**Overall completion: ${pct}%** (${summary.done}/${summary.total} items done)`);
343
+ lines.push('');
344
+
345
+ // Overall summary
346
+ if (overallSummary) {
347
+ lines.push('## Summary');
348
+ lines.push('');
349
+ lines.push(overallSummary);
350
+ lines.push('');
351
+ }
352
+
353
+ // Per-status sections
354
+ const statusOrder = [STATUS.DONE, STATUS.IN_PROGRESS, STATUS.NOT_STARTED, STATUS.SUPERSEDED];
355
+ const statusLabels = {
356
+ DONE: 'Completed Items',
357
+ IN_PROGRESS: 'In Progress',
358
+ NOT_STARTED: 'Not Started',
359
+ SUPERSEDED: 'Superseded',
360
+ };
361
+
362
+ for (const status of statusOrder) {
363
+ const items = assessments.filter(a => a.status === status);
364
+ if (items.length === 0) continue;
365
+
366
+ lines.push(`## ${STATUS_ICONS[status]} ${statusLabels[status]}`);
367
+ lines.push('');
368
+
369
+ for (const item of items) {
370
+ lines.push(`### ${item.item_id} (${item.item_type})`);
371
+ if (item.title) lines.push(`**${item.title}**`);
372
+ lines.push('');
373
+ lines.push(`- **Confidence:** ${item.confidence}`);
374
+ if (item.notes) lines.push(`- **Notes:** ${item.notes}`);
375
+ if (item.evidence && item.evidence.length > 0) {
376
+ lines.push('- **Evidence:**');
377
+ for (const e of item.evidence) {
378
+ if (typeof e === 'string') {
379
+ lines.push(` - ${e}`);
380
+ } else {
381
+ lines.push(` - [${e.type}] ${e.detail}`);
382
+ }
383
+ }
384
+ }
385
+ lines.push('');
386
+ }
387
+ }
388
+
389
+ // Recommendations
390
+ if (recommendations && recommendations.length > 0) {
391
+ lines.push('## Recommendations');
392
+ lines.push('');
393
+ for (const r of recommendations) {
394
+ lines.push(`- ${r}`);
395
+ }
396
+ lines.push('');
397
+ }
398
+
399
+ // Git details
400
+ if (changeReport.git.available && changeReport.git.commits.length > 0) {
401
+ lines.push('## Git Activity Summary');
402
+ lines.push('');
403
+ lines.push(`- **${changeReport.git.commits.length}** commit(s) since last analysis`);
404
+ lines.push(`- **${changeReport.git.changedFiles.length}** file(s) changed`);
405
+ if (changeReport.git.summary) {
406
+ lines.push(`- Diff summary: ${changeReport.git.summary}`);
407
+ }
408
+ lines.push('');
409
+
410
+ // Top changed files
411
+ const topFiles = changeReport.git.changedFiles
412
+ .sort((a, b) => b.changes - a.changes)
413
+ .slice(0, 15);
414
+ if (topFiles.length > 0) {
415
+ lines.push('### Top Changed Files');
416
+ lines.push('');
417
+ lines.push('| File | Status | Changes |');
418
+ lines.push('|------|--------|---------|');
419
+ for (const f of topFiles) {
420
+ lines.push(`| ${f.path} | ${f.status} | ${f.changes} |`);
421
+ }
422
+ lines.push('');
423
+ }
424
+ }
425
+
426
+ // Document changes
427
+ if (changeReport.documents.changes.length > 0) {
428
+ lines.push('## Updated Documents');
429
+ lines.push('');
430
+ for (const d of changeReport.documents.changes) {
431
+ lines.push(`- ${d.relPath} (updated: ${d.modified})`);
432
+ }
433
+ lines.push('');
434
+ }
435
+
436
+ lines.push('---');
437
+ lines.push('*Generated by Smart Change Detection (v8.0.0)*');
438
+
439
+ return lines.join('\n');
440
+ }
441
+
442
+ module.exports = {
443
+ STATUS,
444
+ STATUS_ICONS,
445
+ assessProgressLocal,
446
+ assessProgressWithAI,
447
+ buildProgressPrompt,
448
+ mergeProgressIntoAnalysis,
449
+ buildProgressSummary,
450
+ renderProgressMarkdown,
451
+ };
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Progress persistence — checkpoint/resume for long-running pipelines.
3
+ *
4
+ * Saves pipeline state to a JSON file so that if the process crashes,
5
+ * it can resume from where it left off instead of re-doing everything.
6
+ *
7
+ * State file: <targetDir>/.pipeline-state.json
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const STATE_FILE = '.pipeline-state.json';
16
+
17
+ /**
18
+ * @typedef {object} PipelineState
19
+ * @property {string} startedAt - ISO timestamp
20
+ * @property {string} callName - Name of the call
21
+ * @property {string} userName - User's name
22
+ * @property {string} phase - Current phase: compress|upload|analyze|compile|output
23
+ * @property {object} compression - Per-video compression status
24
+ * @property {object} uploads - Per-segment upload status (storagePath → url)
25
+ * @property {object} analyses - Per-segment analysis status (index → runFile)
26
+ * @property {boolean} compilationDone - Whether compilation is complete
27
+ * @property {string} updatedAt - Last update timestamp
28
+ */
29
+
30
+ class Progress {
31
+ /**
32
+ * @param {string} targetDir - Directory for the state file
33
+ */
34
+ constructor(targetDir) {
35
+ this.filePath = path.join(targetDir, STATE_FILE);
36
+ this.state = this._load();
37
+ }
38
+
39
+ /**
40
+ * Load existing state or create a fresh one.
41
+ * @returns {PipelineState}
42
+ */
43
+ _load() {
44
+ try {
45
+ if (fs.existsSync(this.filePath)) {
46
+ const raw = fs.readFileSync(this.filePath, 'utf8');
47
+ return JSON.parse(raw);
48
+ }
49
+ } catch {
50
+ // Corrupt state file — start fresh
51
+ }
52
+ return {
53
+ startedAt: new Date().toISOString(),
54
+ callName: null,
55
+ userName: null,
56
+ phase: 'init',
57
+ compression: {}, // videoName → { done: boolean, segmentCount: number }
58
+ uploads: {}, // storagePath → { url, done }
59
+ analyses: {}, // segKey → { runFile, done }
60
+ compilationDone: false,
61
+ updatedAt: new Date().toISOString(),
62
+ };
63
+ }
64
+
65
+ /** Save current state to disk. */
66
+ save() {
67
+ this.state.updatedAt = new Date().toISOString();
68
+ try {
69
+ fs.writeFileSync(this.filePath, JSON.stringify(this.state, null, 2), 'utf8');
70
+ } catch (err) {
71
+ console.warn(` ⚠ Could not save progress: ${err.message}`);
72
+ }
73
+ }
74
+
75
+ /** Initialize state for a new run. */
76
+ init(callName, userName) {
77
+ this.state.callName = callName;
78
+ this.state.userName = userName;
79
+ this.state.phase = 'init';
80
+ this.save();
81
+ }
82
+
83
+ /** Set current phase. */
84
+ setPhase(phase) {
85
+ this.state.phase = phase;
86
+ this.save();
87
+ }
88
+
89
+ /** Mark a video's compression as done. */
90
+ markCompressed(videoName, segmentCount) {
91
+ this.state.compression[videoName] = { done: true, segmentCount };
92
+ this.save();
93
+ }
94
+
95
+ /** Check if a video has been compressed. */
96
+ isCompressed(videoName) {
97
+ return this.state.compression[videoName]?.done === true;
98
+ }
99
+
100
+ /** Mark a segment upload as done. */
101
+ markUploaded(storagePath, url) {
102
+ this.state.uploads[storagePath] = { url, done: true };
103
+ this.save();
104
+ }
105
+
106
+ /** Check if a segment has been uploaded. Returns URL or null. */
107
+ getUploadUrl(storagePath) {
108
+ const entry = this.state.uploads[storagePath];
109
+ return entry?.done ? entry.url : null;
110
+ }
111
+
112
+ /** Mark a segment analysis as done. */
113
+ markAnalyzed(segKey, runFile) {
114
+ this.state.analyses[segKey] = { runFile, done: true };
115
+ this.save();
116
+ }
117
+
118
+ /** Check if a segment has been analyzed. */
119
+ isAnalyzed(segKey) {
120
+ return this.state.analyses[segKey]?.done === true;
121
+ }
122
+
123
+ /** Mark compilation as done. */
124
+ markCompilationDone() {
125
+ this.state.compilationDone = true;
126
+ this.save();
127
+ }
128
+
129
+ /** Check if compilation is done. */
130
+ isCompilationDone() {
131
+ return this.state.compilationDone === true;
132
+ }
133
+
134
+ /** Remove the state file (on successful completion). */
135
+ cleanup() {
136
+ try {
137
+ if (fs.existsSync(this.filePath)) {
138
+ fs.unlinkSync(this.filePath);
139
+ }
140
+ } catch { /* ignore */ }
141
+ }
142
+
143
+ /** Check if there is an existing resume state. */
144
+ hasResumableState() {
145
+ return this.state.phase !== 'init' && (
146
+ Object.keys(this.state.compression).length > 0 ||
147
+ Object.keys(this.state.uploads).length > 0 ||
148
+ Object.keys(this.state.analyses).length > 0
149
+ );
150
+ }
151
+
152
+ /** Print a summary of what can be resumed. */
153
+ printResumeSummary() {
154
+ const comp = Object.values(this.state.compression).filter(c => c.done).length;
155
+ const uploads = Object.values(this.state.uploads).filter(u => u.done).length;
156
+ const analyses = Object.values(this.state.analyses).filter(a => a.done).length;
157
+ console.log(` Resume state found (${this.state.updatedAt}):`);
158
+ console.log(` Phase: ${this.state.phase}`);
159
+ console.log(` Compressions done: ${comp}`);
160
+ console.log(` Uploads done: ${uploads}`);
161
+ console.log(` Analyses done: ${analyses}`);
162
+ console.log(` Compilation: ${this.state.compilationDone ? 'done' : 'pending'}`);
163
+ }
164
+ }
165
+
166
+ module.exports = Progress;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Interactive CLI prompts (stdin/stdout).
3
+ */
4
+
5
+ 'use strict';
6
+
7
+ /** Prompt user for a yes/no question on stdin. Returns true for yes. */
8
+ function promptUser(question) {
9
+ const readline = require('readline');
10
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
11
+ return new Promise(resolve => {
12
+ rl.question(question, answer => {
13
+ rl.close();
14
+ const a = (answer || '').trim().toLowerCase();
15
+ resolve(a === 'y' || a === 'yes');
16
+ });
17
+ });
18
+ }
19
+
20
+ /** Prompt user for free text input. Returns trimmed string. */
21
+ function promptUserText(question) {
22
+ const readline = require('readline');
23
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
24
+ return new Promise(resolve => {
25
+ rl.question(question, answer => {
26
+ rl.close();
27
+ resolve((answer || '').trim());
28
+ });
29
+ });
30
+ }
31
+
32
+ module.exports = { promptUser, promptUserText };