@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.
- package/dist/__tests__/backlog-prune.test.js +478 -0
- package/dist/__tests__/deps-operations.test.js +206 -0
- package/dist/__tests__/file-operations.test.js +906 -0
- package/dist/__tests__/git-operations.test.js +668 -0
- package/dist/__tests__/guards-validation.test.js +416 -0
- package/dist/__tests__/init-plan.test.js +340 -0
- package/dist/__tests__/lumenflow-upgrade.test.js +107 -0
- package/dist/__tests__/metrics-cli.test.js +619 -0
- package/dist/__tests__/rotate-progress.test.js +127 -0
- package/dist/__tests__/session-coordinator.test.js +109 -0
- package/dist/__tests__/state-bootstrap.test.js +432 -0
- package/dist/__tests__/trace-gen.test.js +115 -0
- package/dist/backlog-prune.js +299 -0
- package/dist/deps-add.js +215 -0
- package/dist/deps-remove.js +94 -0
- package/dist/file-delete.js +236 -0
- package/dist/file-edit.js +247 -0
- package/dist/file-read.js +197 -0
- package/dist/file-write.js +220 -0
- package/dist/git-branch.js +187 -0
- package/dist/git-diff.js +177 -0
- package/dist/git-log.js +230 -0
- package/dist/git-status.js +208 -0
- package/dist/guard-locked.js +169 -0
- package/dist/guard-main-branch.js +202 -0
- package/dist/guard-worktree-commit.js +160 -0
- package/dist/init-plan.js +337 -0
- package/dist/lumenflow-upgrade.js +178 -0
- package/dist/metrics-cli.js +433 -0
- package/dist/rotate-progress.js +247 -0
- package/dist/session-coordinator.js +300 -0
- package/dist/state-bootstrap.js +307 -0
- package/dist/trace-gen.js +331 -0
- package/dist/validate-agent-skills.js +218 -0
- package/dist/validate-agent-sync.js +148 -0
- package/dist/validate-backlog-sync.js +152 -0
- package/dist/validate-skills-spec.js +206 -0
- package/dist/validate.js +230 -0
- 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
|
+
}
|