@lumenflow/cli 1.6.0 → 2.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 (39) hide show
  1. package/dist/__tests__/backlog-prune.test.js +478 -0
  2. package/dist/__tests__/deps-operations.test.js +206 -0
  3. package/dist/__tests__/file-operations.test.js +906 -0
  4. package/dist/__tests__/git-operations.test.js +668 -0
  5. package/dist/__tests__/guards-validation.test.js +416 -0
  6. package/dist/__tests__/init-plan.test.js +340 -0
  7. package/dist/__tests__/lumenflow-upgrade.test.js +107 -0
  8. package/dist/__tests__/metrics-cli.test.js +619 -0
  9. package/dist/__tests__/rotate-progress.test.js +127 -0
  10. package/dist/__tests__/session-coordinator.test.js +109 -0
  11. package/dist/__tests__/state-bootstrap.test.js +432 -0
  12. package/dist/__tests__/trace-gen.test.js +115 -0
  13. package/dist/backlog-prune.js +299 -0
  14. package/dist/deps-add.js +215 -0
  15. package/dist/deps-remove.js +94 -0
  16. package/dist/file-delete.js +236 -0
  17. package/dist/file-edit.js +247 -0
  18. package/dist/file-read.js +197 -0
  19. package/dist/file-write.js +220 -0
  20. package/dist/git-branch.js +187 -0
  21. package/dist/git-diff.js +177 -0
  22. package/dist/git-log.js +230 -0
  23. package/dist/git-status.js +208 -0
  24. package/dist/guard-locked.js +169 -0
  25. package/dist/guard-main-branch.js +202 -0
  26. package/dist/guard-worktree-commit.js +160 -0
  27. package/dist/init-plan.js +337 -0
  28. package/dist/lumenflow-upgrade.js +178 -0
  29. package/dist/metrics-cli.js +433 -0
  30. package/dist/rotate-progress.js +247 -0
  31. package/dist/session-coordinator.js +300 -0
  32. package/dist/state-bootstrap.js +307 -0
  33. package/dist/trace-gen.js +331 -0
  34. package/dist/validate-agent-skills.js +218 -0
  35. package/dist/validate-agent-sync.js +148 -0
  36. package/dist/validate-backlog-sync.js +152 -0
  37. package/dist/validate-skills-spec.js +206 -0
  38. package/dist/validate.js +230 -0
  39. package/package.json +34 -6
@@ -0,0 +1,433 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Unified Metrics CLI with subcommands (WU-1110)
4
+ *
5
+ * Provides lanes, dora, and flow metrics subcommands in a single CLI.
6
+ *
7
+ * Usage:
8
+ * pnpm metrics # All metrics, JSON output
9
+ * pnpm metrics lanes # Lane health only
10
+ * pnpm metrics dora # DORA metrics only
11
+ * pnpm metrics flow # Flow state only
12
+ * pnpm metrics --format table # Table output
13
+ * pnpm metrics --days 30 # 30 day window
14
+ * pnpm metrics --output metrics.json # Custom output file
15
+ * pnpm metrics --dry-run # Preview without writing
16
+ *
17
+ * @module metrics-cli
18
+ */
19
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
20
+ import { existsSync } from 'node:fs';
21
+ import { join, dirname } from 'node:path';
22
+ import fg from 'fast-glob';
23
+ import { parse as parseYaml } from 'yaml';
24
+ import { Command } from 'commander';
25
+ import { captureMetricsSnapshot, calculateDORAMetrics, calculateFlowState, } from '@lumenflow/metrics';
26
+ import { getGitForCwd } from '@lumenflow/core/dist/git-adapter.js';
27
+ import { die } from '@lumenflow/core/dist/error-handler.js';
28
+ /** Log prefix for console output */
29
+ const LOG_PREFIX = '[metrics]';
30
+ /** Default snapshot output path */
31
+ const DEFAULT_OUTPUT = '.lumenflow/snapshots/metrics-latest.json';
32
+ /** WU directory relative to repo root */
33
+ const WU_DIR = 'docs/04-operations/tasks/wu';
34
+ /** Skip-gates audit file path */
35
+ const SKIP_GATES_PATH = '.lumenflow/skip-gates-audit.ndjson';
36
+ /**
37
+ * Parse command line arguments
38
+ */
39
+ export function parseCommand(argv) {
40
+ let subcommand = 'all';
41
+ let days = 7;
42
+ let format = 'json';
43
+ let output = DEFAULT_OUTPUT;
44
+ let dryRun = false;
45
+ const program = new Command()
46
+ .name('metrics')
47
+ .description('LumenFlow metrics CLI - lanes, dora, flow subcommands')
48
+ .argument('[subcommand]', 'Subcommand: lanes, dora, flow, or all (default)')
49
+ .option('--days <number>', 'Days to analyze (default: 7)', '7')
50
+ .option('--format <type>', 'Output format: json, table (default: json)', 'json')
51
+ .option('--output <path>', `Output file path (default: ${DEFAULT_OUTPUT})`, DEFAULT_OUTPUT)
52
+ .option('--dry-run', 'Preview without writing to file')
53
+ .exitOverride();
54
+ try {
55
+ program.parse(argv);
56
+ const opts = program.opts();
57
+ const args = program.args;
58
+ // Parse subcommand
59
+ if (args.length > 0) {
60
+ const cmd = args[0];
61
+ if (cmd === 'lanes' || cmd === 'dora' || cmd === 'flow' || cmd === 'all') {
62
+ subcommand = cmd;
63
+ }
64
+ }
65
+ days = parseInt(opts.days, 10);
66
+ format = opts.format === 'table' ? 'table' : 'json';
67
+ output = opts.output;
68
+ dryRun = opts.dryRun === true;
69
+ }
70
+ catch (err) {
71
+ const error = err;
72
+ if (error.code === 'commander.helpDisplayed' || error.code === 'commander.version') {
73
+ process.exit(0);
74
+ }
75
+ }
76
+ return { subcommand, days, format, output, dryRun };
77
+ }
78
+ /**
79
+ * Calculate week date range
80
+ */
81
+ function calculateWeekRange(days) {
82
+ const weekEnd = new Date();
83
+ weekEnd.setHours(23, 59, 59, 999);
84
+ const weekStart = new Date(weekEnd);
85
+ weekStart.setDate(weekStart.getDate() - days);
86
+ weekStart.setHours(0, 0, 0, 0);
87
+ return { weekStart, weekEnd };
88
+ }
89
+ /**
90
+ * Calculate cycle time in hours from WU data
91
+ */
92
+ function calculateCycleTime(wu) {
93
+ if (!wu.claimed_at || !wu.completed_at) {
94
+ return undefined;
95
+ }
96
+ const claimed = new Date(wu.claimed_at);
97
+ const completed = new Date(wu.completed_at);
98
+ const diffMs = completed.getTime() - claimed.getTime();
99
+ const diffHours = diffMs / (1000 * 60 * 60);
100
+ return Math.round(diffHours * 10) / 10;
101
+ }
102
+ /**
103
+ * Load WU metrics from YAML files
104
+ */
105
+ async function loadWUMetrics(baseDir) {
106
+ const wuDir = join(baseDir, WU_DIR);
107
+ const wuFiles = await fg('WU-*.yaml', { cwd: wuDir, absolute: true });
108
+ const wuMetrics = [];
109
+ for (const file of wuFiles) {
110
+ try {
111
+ const content = await readFile(file, { encoding: 'utf-8' });
112
+ const wu = parseYaml(content);
113
+ // Map WU status to valid WUMetrics status
114
+ const rawStatus = wu.status;
115
+ let status = 'ready';
116
+ if (rawStatus === 'in_progress')
117
+ status = 'in_progress';
118
+ else if (rawStatus === 'blocked')
119
+ status = 'blocked';
120
+ else if (rawStatus === 'waiting')
121
+ status = 'waiting';
122
+ else if (rawStatus === 'done')
123
+ status = 'done';
124
+ else if (rawStatus === 'ready')
125
+ status = 'ready';
126
+ wuMetrics.push({
127
+ id: wu.id,
128
+ title: wu.title,
129
+ lane: wu.lane,
130
+ status,
131
+ priority: wu.priority,
132
+ claimedAt: wu.claimed_at ? new Date(wu.claimed_at) : undefined,
133
+ completedAt: wu.completed_at ? new Date(wu.completed_at) : undefined,
134
+ cycleTimeHours: calculateCycleTime(wu),
135
+ });
136
+ }
137
+ catch {
138
+ // Skip invalid WU files
139
+ }
140
+ }
141
+ return wuMetrics;
142
+ }
143
+ /**
144
+ * Load git commits from repository
145
+ */
146
+ async function loadGitCommits(weekStart, weekEnd) {
147
+ try {
148
+ const git = getGitForCwd();
149
+ const logResult = await git.log({ maxCount: 500 });
150
+ const commits = [];
151
+ for (const entry of [...logResult.all]) {
152
+ const commitDate = new Date(entry.date);
153
+ if (commitDate < weekStart || commitDate > weekEnd) {
154
+ continue;
155
+ }
156
+ const message = entry.message;
157
+ const wuIdMatch = message.match(/\b(WU-\d+)\b/i);
158
+ const wuId = wuIdMatch ? wuIdMatch[1].toUpperCase() : undefined;
159
+ const typeMatch = message.match(/^(feat|fix|docs|chore|refactor|test|style|perf|ci)[\(:]?/i);
160
+ const type = typeMatch ? typeMatch[1].toLowerCase() : undefined;
161
+ commits.push({
162
+ hash: entry.hash,
163
+ timestamp: commitDate,
164
+ message,
165
+ type,
166
+ wuId,
167
+ });
168
+ }
169
+ return commits;
170
+ }
171
+ catch (err) {
172
+ console.warn(`${LOG_PREFIX} Could not load git commits: ${err.message}`);
173
+ return [];
174
+ }
175
+ }
176
+ /**
177
+ * Load skip-gates audit entries
178
+ */
179
+ async function loadSkipGatesEntries(baseDir) {
180
+ const auditPath = join(baseDir, SKIP_GATES_PATH);
181
+ if (!existsSync(auditPath)) {
182
+ return [];
183
+ }
184
+ try {
185
+ const content = await readFile(auditPath, { encoding: 'utf-8' });
186
+ const lines = content.split('\n').filter((line) => line.trim());
187
+ const entries = [];
188
+ for (const line of lines) {
189
+ try {
190
+ const raw = JSON.parse(line);
191
+ if (raw.timestamp && raw.wu_id && raw.reason && raw.gate) {
192
+ entries.push({
193
+ timestamp: new Date(raw.timestamp),
194
+ wuId: raw.wu_id,
195
+ reason: raw.reason,
196
+ gate: raw.gate,
197
+ });
198
+ }
199
+ }
200
+ catch {
201
+ // Skip invalid JSON lines
202
+ }
203
+ }
204
+ return entries;
205
+ }
206
+ catch {
207
+ return [];
208
+ }
209
+ }
210
+ /**
211
+ * Calculate lane health from WU metrics
212
+ */
213
+ export function calculateLaneHealthFromWUs(wuMetrics) {
214
+ // Use captureMetricsSnapshot with 'lanes' type
215
+ const snapshot = captureMetricsSnapshot({
216
+ commits: [],
217
+ wuMetrics,
218
+ skipGatesEntries: [],
219
+ weekStart: new Date(),
220
+ weekEnd: new Date(),
221
+ type: 'lanes',
222
+ });
223
+ return snapshot.lanes ?? { lanes: [], totalActive: 0, totalBlocked: 0, totalCompleted: 0 };
224
+ }
225
+ /**
226
+ * Calculate DORA metrics from data
227
+ */
228
+ export function calculateDoraFromData(input) {
229
+ return calculateDORAMetrics(input.commits, input.skipGatesEntries, input.wuMetrics, input.weekStart, input.weekEnd);
230
+ }
231
+ /**
232
+ * Calculate flow state from WU metrics
233
+ */
234
+ export function calculateFlowFromWUs(wuMetrics) {
235
+ return calculateFlowState(wuMetrics);
236
+ }
237
+ /**
238
+ * Format lanes output
239
+ */
240
+ export function formatLanesOutput(lanes, format) {
241
+ if (format === 'table') {
242
+ const lines = [];
243
+ lines.push('LANE HEALTH');
244
+ lines.push('═══════════════════════════════════════════════════════════════');
245
+ lines.push(`Total Active: ${lanes.totalActive} | Blocked: ${lanes.totalBlocked} | Completed: ${lanes.totalCompleted}`);
246
+ lines.push('');
247
+ for (const lane of lanes.lanes) {
248
+ const statusIcon = lane.status === 'healthy' ? '[ok]' : lane.status === 'at-risk' ? '[!]' : '[x]';
249
+ lines.push(`${statusIcon} ${lane.lane.padEnd(25)} ${lane.wusCompleted} done, ${lane.wusInProgress} active, ${lane.wusBlocked} blocked`);
250
+ }
251
+ return lines.join('\n');
252
+ }
253
+ return JSON.stringify(lanes, null, 2);
254
+ }
255
+ /**
256
+ * Format DORA output
257
+ */
258
+ export function formatDoraOutput(dora, format) {
259
+ if (format === 'table') {
260
+ const lines = [];
261
+ lines.push('DORA METRICS');
262
+ lines.push('═══════════════════════════════════════════════════════════════');
263
+ lines.push(`Deployment Frequency: ${dora.deploymentFrequency.deploysPerWeek}/week (${dora.deploymentFrequency.status})`);
264
+ lines.push(`Lead Time: ${dora.leadTimeForChanges.medianHours}h median (${dora.leadTimeForChanges.status})`);
265
+ lines.push(`Change Failure Rate: ${dora.changeFailureRate.failurePercentage}% (${dora.changeFailureRate.status})`);
266
+ lines.push(`MTTR: ${dora.meanTimeToRecovery.averageHours}h (${dora.meanTimeToRecovery.status})`);
267
+ return lines.join('\n');
268
+ }
269
+ return JSON.stringify(dora, null, 2);
270
+ }
271
+ /**
272
+ * Format flow output
273
+ */
274
+ export function formatFlowOutput(flow, format) {
275
+ if (format === 'table') {
276
+ const lines = [];
277
+ lines.push('FLOW STATE');
278
+ lines.push('═══════════════════════════════════════════════════════════════');
279
+ lines.push(`Ready: ${flow.ready} | In Progress: ${flow.inProgress}`);
280
+ lines.push(`Blocked: ${flow.blocked} | Waiting: ${flow.waiting}`);
281
+ lines.push(`Done: ${flow.done} | Total Active: ${flow.totalActive}`);
282
+ return lines.join('\n');
283
+ }
284
+ return JSON.stringify(flow, null, 2);
285
+ }
286
+ /**
287
+ * Run lanes subcommand
288
+ */
289
+ export async function runLanesSubcommand(opts) {
290
+ const baseDir = process.cwd();
291
+ console.log(`${LOG_PREFIX} Calculating lane health...`);
292
+ const wuMetrics = await loadWUMetrics(baseDir);
293
+ console.log(`${LOG_PREFIX} Found ${wuMetrics.length} WUs`);
294
+ const lanes = calculateLaneHealthFromWUs(wuMetrics);
295
+ const output = formatLanesOutput(lanes, opts.format);
296
+ console.log('');
297
+ console.log(output);
298
+ if (!opts.dryRun) {
299
+ await writeOutput(baseDir, opts.output, { type: 'lanes', data: lanes });
300
+ }
301
+ }
302
+ /**
303
+ * Run dora subcommand
304
+ */
305
+ export async function runDoraSubcommand(opts) {
306
+ const baseDir = process.cwd();
307
+ const { weekStart, weekEnd } = calculateWeekRange(opts.days);
308
+ console.log(`${LOG_PREFIX} Calculating DORA metrics...`);
309
+ console.log(`${LOG_PREFIX} Date range: ${weekStart.toISOString().split('T')[0]} to ${weekEnd.toISOString().split('T')[0]}`);
310
+ const [wuMetrics, commits, skipGatesEntries] = await Promise.all([
311
+ loadWUMetrics(baseDir),
312
+ loadGitCommits(weekStart, weekEnd),
313
+ loadSkipGatesEntries(baseDir),
314
+ ]);
315
+ console.log(`${LOG_PREFIX} Found ${wuMetrics.length} WUs, ${commits.length} commits`);
316
+ const dora = calculateDoraFromData({ commits, wuMetrics, skipGatesEntries, weekStart, weekEnd });
317
+ const output = formatDoraOutput(dora, opts.format);
318
+ console.log('');
319
+ console.log(output);
320
+ if (!opts.dryRun) {
321
+ await writeOutput(baseDir, opts.output, { type: 'dora', data: dora });
322
+ }
323
+ }
324
+ /**
325
+ * Run flow subcommand
326
+ */
327
+ export async function runFlowSubcommand(opts) {
328
+ const baseDir = process.cwd();
329
+ console.log(`${LOG_PREFIX} Calculating flow state...`);
330
+ const wuMetrics = await loadWUMetrics(baseDir);
331
+ console.log(`${LOG_PREFIX} Found ${wuMetrics.length} WUs`);
332
+ const flow = calculateFlowFromWUs(wuMetrics);
333
+ const output = formatFlowOutput(flow, opts.format);
334
+ console.log('');
335
+ console.log(output);
336
+ if (!opts.dryRun) {
337
+ await writeOutput(baseDir, opts.output, { type: 'flow', data: flow });
338
+ }
339
+ }
340
+ /**
341
+ * Run all metrics (default)
342
+ */
343
+ export async function runAllSubcommand(opts) {
344
+ const baseDir = process.cwd();
345
+ const { weekStart, weekEnd } = calculateWeekRange(opts.days);
346
+ console.log(`${LOG_PREFIX} Capturing all metrics...`);
347
+ console.log(`${LOG_PREFIX} Date range: ${weekStart.toISOString().split('T')[0]} to ${weekEnd.toISOString().split('T')[0]}`);
348
+ const [wuMetrics, commits, skipGatesEntries] = await Promise.all([
349
+ loadWUMetrics(baseDir),
350
+ loadGitCommits(weekStart, weekEnd),
351
+ loadSkipGatesEntries(baseDir),
352
+ ]);
353
+ console.log(`${LOG_PREFIX} Found ${wuMetrics.length} WUs, ${commits.length} commits`);
354
+ const input = {
355
+ commits,
356
+ wuMetrics,
357
+ skipGatesEntries,
358
+ weekStart,
359
+ weekEnd,
360
+ type: 'all',
361
+ };
362
+ const snapshot = captureMetricsSnapshot(input);
363
+ // Format based on output preference
364
+ if (opts.format === 'table') {
365
+ if (snapshot.dora) {
366
+ console.log('');
367
+ console.log(formatDoraOutput(snapshot.dora, 'table'));
368
+ }
369
+ if (snapshot.lanes) {
370
+ console.log('');
371
+ console.log(formatLanesOutput(snapshot.lanes, 'table'));
372
+ }
373
+ if (snapshot.flow) {
374
+ console.log('');
375
+ console.log(formatFlowOutput(snapshot.flow, 'table'));
376
+ }
377
+ }
378
+ else {
379
+ console.log('');
380
+ console.log(JSON.stringify(snapshot, null, 2));
381
+ }
382
+ if (!opts.dryRun) {
383
+ await writeOutput(baseDir, opts.output, {
384
+ type: 'all',
385
+ capturedAt: new Date().toISOString(),
386
+ dateRange: {
387
+ start: weekStart.toISOString(),
388
+ end: weekEnd.toISOString(),
389
+ },
390
+ snapshot,
391
+ });
392
+ }
393
+ }
394
+ /**
395
+ * Write output to file
396
+ */
397
+ async function writeOutput(baseDir, outputPath, data) {
398
+ const fullPath = join(baseDir, outputPath);
399
+ const outputDir = dirname(fullPath);
400
+ if (!existsSync(outputDir)) {
401
+ await mkdir(outputDir, { recursive: true });
402
+ }
403
+ await writeFile(fullPath, JSON.stringify(data, null, 2), { encoding: 'utf-8' });
404
+ console.log(`${LOG_PREFIX} Output written to: ${fullPath}`);
405
+ }
406
+ /**
407
+ * Main entry point
408
+ */
409
+ async function main() {
410
+ const opts = parseCommand(process.argv);
411
+ switch (opts.subcommand) {
412
+ case 'lanes':
413
+ await runLanesSubcommand(opts);
414
+ break;
415
+ case 'dora':
416
+ await runDoraSubcommand(opts);
417
+ break;
418
+ case 'flow':
419
+ await runFlowSubcommand(opts);
420
+ break;
421
+ case 'all':
422
+ default:
423
+ await runAllSubcommand(opts);
424
+ break;
425
+ }
426
+ }
427
+ // Guard main() for testability
428
+ import { fileURLToPath } from 'node:url';
429
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
430
+ main().catch((err) => {
431
+ die(`Metrics command failed: ${err.message}`);
432
+ });
433
+ }
@@ -0,0 +1,247 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Rotate Progress CLI Command
4
+ *
5
+ * Moves completed WUs from status.md In Progress section to Completed section.
6
+ * Keeps the status file tidy by archiving done work.
7
+ *
8
+ * WU-1112: INIT-003 Phase 6 - Migrate remaining Tier 1 tools
9
+ *
10
+ * Usage:
11
+ * pnpm rotate:progress
12
+ * pnpm rotate:progress --dry-run
13
+ * pnpm rotate:progress --limit 10
14
+ */
15
+ import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { parse as parseYaml } from 'yaml';
18
+ import { EXIT_CODES, STATUS_SECTIONS, DIRECTORIES, FILE_SYSTEM, } from '@lumenflow/core/dist/wu-constants.js';
19
+ import { runCLI } from './cli-entry-point.js';
20
+ /** Log prefix for console output */
21
+ const LOG_PREFIX = '[rotate:progress]';
22
+ /**
23
+ * Parse command line arguments for rotate-progress
24
+ *
25
+ * @param argv - Process argv array
26
+ * @returns Parsed arguments
27
+ */
28
+ export function parseRotateArgs(argv) {
29
+ const args = {};
30
+ // Skip node and script name
31
+ const cliArgs = argv.slice(2);
32
+ for (let i = 0; i < cliArgs.length; i++) {
33
+ const arg = cliArgs[i];
34
+ if (arg === '--help' || arg === '-h') {
35
+ args.help = true;
36
+ }
37
+ else if (arg === '--dry-run' || arg === '-n') {
38
+ args.dryRun = true;
39
+ }
40
+ else if (arg === '--limit' || arg === '-l') {
41
+ const val = cliArgs[++i];
42
+ if (val)
43
+ args.limit = parseInt(val, 10);
44
+ }
45
+ }
46
+ return args;
47
+ }
48
+ /**
49
+ * Get WU status from YAML file
50
+ */
51
+ function getWuStatus(wuId, baseDir = process.cwd()) {
52
+ const yamlPath = join(baseDir, DIRECTORIES.WU_DIR, `${wuId}.yaml`);
53
+ if (!existsSync(yamlPath)) {
54
+ return null;
55
+ }
56
+ try {
57
+ const content = readFileSync(yamlPath, { encoding: FILE_SYSTEM.ENCODING });
58
+ const yaml = parseYaml(content);
59
+ return yaml?.status || null;
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+ /**
66
+ * Get all WU statuses from YAML files
67
+ */
68
+ function getAllWuStatuses(baseDir = process.cwd()) {
69
+ const statuses = new Map();
70
+ const wuDir = join(baseDir, DIRECTORIES.WU_DIR);
71
+ if (!existsSync(wuDir)) {
72
+ return statuses;
73
+ }
74
+ const files = readdirSync(wuDir);
75
+ for (const file of files) {
76
+ if (file.endsWith('.yaml') || file.endsWith('.yml')) {
77
+ const wuId = file.replace(/\.ya?ml$/, '');
78
+ const status = getWuStatus(wuId, baseDir);
79
+ if (status) {
80
+ statuses.set(wuId, status);
81
+ }
82
+ }
83
+ }
84
+ return statuses;
85
+ }
86
+ /**
87
+ * Find WUs in the In Progress section that have status=done in YAML
88
+ *
89
+ * @param statusContent - Content of status.md file
90
+ * @param wuStatuses - Map of WU IDs to their statuses from YAML
91
+ * @returns Array of WU IDs that should be moved to Completed
92
+ */
93
+ export function findCompletedWUs(statusContent, wuStatuses) {
94
+ const completed = [];
95
+ // Find the In Progress section
96
+ const inProgressStart = statusContent.indexOf(STATUS_SECTIONS.IN_PROGRESS);
97
+ if (inProgressStart === -1) {
98
+ return completed;
99
+ }
100
+ // Find the end of In Progress section (next ## heading or end of file)
101
+ const afterInProgress = statusContent.slice(inProgressStart + STATUS_SECTIONS.IN_PROGRESS.length);
102
+ const nextSectionMatch = afterInProgress.match(/\n##/);
103
+ const inProgressSection = nextSectionMatch
104
+ ? afterInProgress.slice(0, nextSectionMatch.index)
105
+ : afterInProgress;
106
+ // Extract WU IDs from In Progress section
107
+ const wuIdMatches = inProgressSection.match(/WU-\d+/g) || [];
108
+ const uniqueWuIds = [...new Set(wuIdMatches)];
109
+ // Check which ones have done status
110
+ for (const wuId of uniqueWuIds) {
111
+ const status = wuStatuses.get(wuId);
112
+ if (status === 'done' || status === 'completed') {
113
+ completed.push(wuId);
114
+ }
115
+ }
116
+ return completed;
117
+ }
118
+ /**
119
+ * Build the rotated status.md content
120
+ *
121
+ * @param statusContent - Original status.md content
122
+ * @param completedWUs - WU IDs to move to Completed
123
+ * @returns Updated status.md content
124
+ */
125
+ export function buildRotatedContent(statusContent, completedWUs) {
126
+ if (completedWUs.length === 0) {
127
+ return statusContent;
128
+ }
129
+ let content = statusContent;
130
+ const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
131
+ // For each completed WU, move it from In Progress to Completed
132
+ for (const wuId of completedWUs) {
133
+ // Find and remove the line from In Progress section
134
+ const wuLineRegex = new RegExp(`\\n?-\\s*\\[?[\\sx]?\\]?\\s*\\[?${wuId}[^\\n]*`, 'gi');
135
+ const match = content.match(wuLineRegex);
136
+ if (match) {
137
+ // Extract the original line text
138
+ const originalLine = match[0].trim();
139
+ // Remove from current position
140
+ content = content.replace(wuLineRegex, '');
141
+ // Extract title from the original line
142
+ // Match "WU-XXXX - Title" or "WU-XXXX Title" patterns
143
+ const titleMatch = originalLine.match(/WU-\d+\s*[-—]?\s*([^(]*)/);
144
+ let title = wuId;
145
+ if (titleMatch) {
146
+ const fullMatch = titleMatch[0].trim();
147
+ const wuPart = wuId;
148
+ // Get everything after the WU ID
149
+ const rest = fullMatch
150
+ .slice(wuId.length)
151
+ .replace(/^[\s-—]+/, '')
152
+ .trim();
153
+ title = rest ? `${wuPart} - ${rest}` : wuPart;
154
+ }
155
+ // Build the completed entry with date
156
+ const completedEntry = `- [x] ${title} (${today})`;
157
+ // Add to Completed section
158
+ const completedSectionIndex = content.indexOf(STATUS_SECTIONS.COMPLETED);
159
+ if (completedSectionIndex !== -1) {
160
+ const insertPoint = completedSectionIndex + STATUS_SECTIONS.COMPLETED.length;
161
+ content =
162
+ content.slice(0, insertPoint) + '\n' + completedEntry + content.slice(insertPoint);
163
+ }
164
+ }
165
+ }
166
+ // Clean up any double newlines
167
+ content = content.replace(/\n{3,}/g, '\n\n');
168
+ return content;
169
+ }
170
+ /**
171
+ * Print help message for rotate-progress
172
+ */
173
+ /* istanbul ignore next -- CLI entry point */
174
+ function printHelp() {
175
+ console.log(`
176
+ Usage: rotate-progress [options]
177
+
178
+ Move completed WUs from status.md In Progress to Completed section.
179
+
180
+ Options:
181
+ -n, --dry-run Show changes without writing
182
+ -l, --limit <n> Maximum number of WUs to rotate
183
+ -h, --help Show this help message
184
+
185
+ How it works:
186
+ 1. Scans status.md for WUs listed in "In Progress" section
187
+ 2. Checks each WU's YAML file for status=done
188
+ 3. Moves done WUs to "Completed" section with date stamp
189
+
190
+ Examples:
191
+ rotate:progress # Rotate all completed WUs
192
+ rotate:progress --dry-run # Preview what would be rotated
193
+ rotate:progress --limit 5 # Rotate at most 5 WUs
194
+ `);
195
+ }
196
+ /**
197
+ * Main entry point for rotate-progress command
198
+ */
199
+ /* istanbul ignore next -- CLI entry point */
200
+ async function main() {
201
+ const args = parseRotateArgs(process.argv);
202
+ if (args.help) {
203
+ printHelp();
204
+ process.exit(EXIT_CODES.SUCCESS);
205
+ }
206
+ // Read status.md
207
+ const statusPath = join(process.cwd(), DIRECTORIES.STATUS_PATH);
208
+ if (!existsSync(statusPath)) {
209
+ console.error(`${LOG_PREFIX} Error: ${statusPath} not found`);
210
+ process.exit(EXIT_CODES.ERROR);
211
+ }
212
+ const statusContent = readFileSync(statusPath, {
213
+ encoding: FILE_SYSTEM.ENCODING,
214
+ });
215
+ // Get all WU statuses
216
+ const wuStatuses = getAllWuStatuses();
217
+ // Find completed WUs
218
+ let completedWUs = findCompletedWUs(statusContent, wuStatuses);
219
+ if (completedWUs.length === 0) {
220
+ console.log(`${LOG_PREFIX} No completed WUs to rotate.`);
221
+ process.exit(EXIT_CODES.SUCCESS);
222
+ }
223
+ // Apply limit if specified
224
+ if (args.limit && args.limit > 0) {
225
+ completedWUs = completedWUs.slice(0, args.limit);
226
+ }
227
+ console.log(`${LOG_PREFIX} Found ${completedWUs.length} WU(s) to rotate:`);
228
+ for (const wuId of completedWUs) {
229
+ console.log(` - ${wuId}`);
230
+ }
231
+ if (args.dryRun) {
232
+ console.log(`\n${LOG_PREFIX} DRY RUN - No changes made.`);
233
+ const newContent = buildRotatedContent(statusContent, completedWUs);
234
+ console.log(`\n${LOG_PREFIX} Preview of changes:`);
235
+ console.log('---');
236
+ console.log(newContent.slice(0, 500) + '...');
237
+ process.exit(EXIT_CODES.SUCCESS);
238
+ }
239
+ // Build and write updated content
240
+ const newContent = buildRotatedContent(statusContent, completedWUs);
241
+ writeFileSync(statusPath, newContent, { encoding: FILE_SYSTEM.ENCODING });
242
+ console.log(`\n${LOG_PREFIX} ✅ Rotated ${completedWUs.length} WU(s) to Completed section.`);
243
+ }
244
+ // Run main if executed directly
245
+ if (import.meta.main) {
246
+ runCLI(main);
247
+ }