@lumenflow/cli 1.0.0 → 1.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,315 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Initiative Bulk Assign WUs CLI (WU-1018)
4
+ *
5
+ * Bulk-assigns orphaned WUs to initiatives based on lane prefix rules.
6
+ * Uses micro-worktree isolation for race-safe commits.
7
+ *
8
+ * Usage:
9
+ * pnpm initiative:bulk-assign # Dry-run (default)
10
+ * LUMENFLOW_ADMIN=1 pnpm initiative:bulk-assign --apply # Apply changes
11
+ * pnpm initiative:bulk-assign --config custom-config.yaml # Custom config
12
+ * pnpm initiative:bulk-assign --reconcile-initiative INIT-001
13
+ *
14
+ * @module initiative-bulk-assign-wus
15
+ */
16
+ import { readFile, writeFile } from 'node:fs/promises';
17
+ import { existsSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import fg from 'fast-glob';
20
+ import { parse as parseYaml } from 'yaml';
21
+ import { createWUParser, WU_OPTIONS } from '@lumenflow/core/dist/arg-parser.js';
22
+ import { die } from '@lumenflow/core/dist/error-handler.js';
23
+ import { withMicroWorktree } from '@lumenflow/core/dist/micro-worktree.js';
24
+ /** Log prefix for console output */
25
+ const LOG_PREFIX = '[initiative:bulk-assign]';
26
+ /** Default lane bucket configuration path */
27
+ const DEFAULT_CONFIG_PATH = 'tools/config/initiative-lane-buckets.yaml';
28
+ /** WU directory relative to repo root */
29
+ const WU_DIR = 'docs/04-operations/tasks/wu';
30
+ /** Initiative directory relative to repo root */
31
+ const INIT_DIR = 'docs/04-operations/tasks/initiatives';
32
+ /** Environment variable required for apply mode */
33
+ const ADMIN_ENV_VAR = 'LUMENFLOW_ADMIN';
34
+ /** Micro-worktree operation name */
35
+ const OPERATION_NAME = 'initiative-bulk-assign';
36
+ /**
37
+ * Load lane bucket configuration
38
+ */
39
+ async function loadConfig(configPath) {
40
+ const fullPath = join(process.cwd(), configPath);
41
+ if (!existsSync(fullPath)) {
42
+ console.log(`${LOG_PREFIX} Config not found: ${configPath}`);
43
+ console.log(`${LOG_PREFIX} Using empty rules (no auto-assignment)`);
44
+ return { rules: [] };
45
+ }
46
+ const content = await readFile(fullPath, { encoding: 'utf-8' });
47
+ return parseYaml(content);
48
+ }
49
+ /**
50
+ * Scan top-level meta from WU YAML content (text-based to preserve formatting)
51
+ */
52
+ function scanTopLevelMeta(text, filePath) {
53
+ const lines = text.split('\n');
54
+ let id;
55
+ let lane;
56
+ let initiative;
57
+ let laneLineIndex = -1;
58
+ for (let i = 0; i < lines.length; i++) {
59
+ const line = lines[i];
60
+ const trimmed = line.trim();
61
+ // Skip comments and empty lines
62
+ if (trimmed.startsWith('#') || trimmed === '' || trimmed === '---') {
63
+ continue;
64
+ }
65
+ // Extract id
66
+ if (trimmed.startsWith('id:')) {
67
+ id = trimmed.replace('id:', '').trim();
68
+ }
69
+ // Extract lane
70
+ if (trimmed.startsWith('lane:')) {
71
+ lane = trimmed.replace('lane:', '').trim();
72
+ laneLineIndex = i;
73
+ }
74
+ // Extract initiative
75
+ if (trimmed.startsWith('initiative:')) {
76
+ initiative = trimmed.replace('initiative:', '').trim();
77
+ }
78
+ }
79
+ if (!id || !lane || laneLineIndex === -1) {
80
+ return null;
81
+ }
82
+ return {
83
+ id,
84
+ lane,
85
+ initiative,
86
+ filePath,
87
+ laneLineIndex,
88
+ rawContent: text,
89
+ };
90
+ }
91
+ /**
92
+ * Insert initiative line after lane line (text-based)
93
+ */
94
+ function insertInitiativeLine(text, laneLineIndex, initiativeId) {
95
+ const lines = text.split('\n');
96
+ const initLine = `initiative: ${initiativeId}`;
97
+ // Insert after lane line
98
+ lines.splice(laneLineIndex + 1, 0, initLine);
99
+ return lines.join('\n');
100
+ }
101
+ /**
102
+ * Match lane against rules to find initiative
103
+ */
104
+ function pickInitiativeForLane(lane, rules) {
105
+ for (const rule of rules) {
106
+ if (lane.toLowerCase().startsWith(rule.lane_prefix.toLowerCase())) {
107
+ return rule.initiative;
108
+ }
109
+ }
110
+ return null;
111
+ }
112
+ /**
113
+ * List all WU files
114
+ */
115
+ async function listWUFiles() {
116
+ const wuDir = join(process.cwd(), WU_DIR);
117
+ return fg('WU-*.yaml', { cwd: wuDir, absolute: true });
118
+ }
119
+ /**
120
+ * List all initiative files
121
+ */
122
+ async function listInitiativeFiles() {
123
+ const initDir = join(process.cwd(), INIT_DIR);
124
+ if (!existsSync(initDir)) {
125
+ return [];
126
+ }
127
+ return fg('INIT-*.yaml', { cwd: initDir, absolute: true });
128
+ }
129
+ /**
130
+ * Load WU IDs from initiative files
131
+ */
132
+ async function loadInitiativeWUs() {
133
+ const initFiles = await listInitiativeFiles();
134
+ const initWUs = new Map();
135
+ for (const file of initFiles) {
136
+ try {
137
+ const content = await readFile(file, { encoding: 'utf-8' });
138
+ const init = parseYaml(content);
139
+ if (init.id && Array.isArray(init.wus)) {
140
+ initWUs.set(init.id, init.wus);
141
+ }
142
+ }
143
+ catch {
144
+ // Skip invalid initiative files
145
+ }
146
+ }
147
+ return initWUs;
148
+ }
149
+ /**
150
+ * Compute all changes without writing
151
+ */
152
+ async function computeChanges(config) {
153
+ const wuFiles = await listWUFiles();
154
+ const initWUs = await loadInitiativeWUs();
155
+ const changes = [];
156
+ const stats = {
157
+ total: wuFiles.length,
158
+ alreadyAssigned: 0,
159
+ newlyAssigned: 0,
160
+ synced: 0,
161
+ skipped: 0,
162
+ };
163
+ // Build reverse lookup: WU ID -> Initiative ID
164
+ const wuToInit = new Map();
165
+ for (const [initId, wuList] of initWUs.entries()) {
166
+ for (const wuId of wuList) {
167
+ wuToInit.set(wuId, initId);
168
+ }
169
+ }
170
+ for (const file of wuFiles) {
171
+ try {
172
+ const content = await readFile(file, { encoding: 'utf-8' });
173
+ const meta = scanTopLevelMeta(content, file);
174
+ if (!meta) {
175
+ stats.skipped++;
176
+ continue;
177
+ }
178
+ // Check if already assigned
179
+ if (meta.initiative) {
180
+ stats.alreadyAssigned++;
181
+ continue;
182
+ }
183
+ // Check if initiative assigns this WU
184
+ const assignedInit = wuToInit.get(meta.id);
185
+ if (assignedInit) {
186
+ // Sync from initiative
187
+ const newContent = insertInitiativeLine(content, meta.laneLineIndex, assignedInit);
188
+ changes.push({
189
+ wuId: meta.id,
190
+ type: 'sync',
191
+ initiative: assignedInit,
192
+ filePath: file,
193
+ newContent,
194
+ });
195
+ stats.synced++;
196
+ continue;
197
+ }
198
+ // Try to auto-assign by lane prefix
199
+ const matchedInit = pickInitiativeForLane(meta.lane, config.rules);
200
+ if (matchedInit) {
201
+ const newContent = insertInitiativeLine(content, meta.laneLineIndex, matchedInit);
202
+ changes.push({
203
+ wuId: meta.id,
204
+ type: 'assign',
205
+ initiative: matchedInit,
206
+ filePath: file,
207
+ newContent,
208
+ });
209
+ stats.newlyAssigned++;
210
+ }
211
+ else {
212
+ stats.skipped++;
213
+ }
214
+ }
215
+ catch {
216
+ stats.skipped++;
217
+ }
218
+ }
219
+ return { changes, stats };
220
+ }
221
+ /**
222
+ * Print summary of changes
223
+ */
224
+ function printSummary(stats) {
225
+ console.log('');
226
+ console.log('═══════════════════════════════════════════════════════════════');
227
+ console.log(' BULK ASSIGNMENT SUMMARY');
228
+ console.log('═══════════════════════════════════════════════════════════════');
229
+ console.log(` Total WUs scanned: ${stats.total}`);
230
+ console.log(` Already assigned: ${stats.alreadyAssigned}`);
231
+ console.log(` Synced from initiatives: ${stats.synced}`);
232
+ console.log(` Newly assigned by lane: ${stats.newlyAssigned}`);
233
+ console.log(` Skipped (no match): ${stats.skipped}`);
234
+ console.log('═══════════════════════════════════════════════════════════════');
235
+ console.log('');
236
+ }
237
+ /**
238
+ * Main function
239
+ */
240
+ async function main() {
241
+ const args = createWUParser({
242
+ name: 'initiative-bulk-assign-wus',
243
+ description: 'Bulk-assign orphaned WUs to initiatives based on lane prefix rules',
244
+ options: [WU_OPTIONS.config, WU_OPTIONS.apply, WU_OPTIONS.syncFromInitiative],
245
+ required: [],
246
+ });
247
+ const configPath = args.config || DEFAULT_CONFIG_PATH;
248
+ const applyMode = args.apply === true;
249
+ console.log(`${LOG_PREFIX} Bulk assign WUs to initiatives`);
250
+ console.log(`${LOG_PREFIX} Config: ${configPath}`);
251
+ console.log(`${LOG_PREFIX} Mode: ${applyMode ? 'APPLY' : 'dry-run'}`);
252
+ // Check admin mode for apply
253
+ if (applyMode && process.env[ADMIN_ENV_VAR] !== '1') {
254
+ die(`Apply mode requires ${ADMIN_ENV_VAR}=1 environment variable.\n\n` +
255
+ `This prevents accidental use by agents.\n\n` +
256
+ `Usage: ${ADMIN_ENV_VAR}=1 pnpm initiative:bulk-assign --apply`);
257
+ }
258
+ // Load configuration
259
+ const config = await loadConfig(configPath);
260
+ console.log(`${LOG_PREFIX} Loaded ${config.rules.length} lane assignment rules`);
261
+ // Compute changes
262
+ console.log(`${LOG_PREFIX} Scanning WUs...`);
263
+ const { changes, stats } = await computeChanges(config);
264
+ // Print summary
265
+ printSummary(stats);
266
+ if (changes.length === 0) {
267
+ console.log(`${LOG_PREFIX} No changes to apply.`);
268
+ return;
269
+ }
270
+ // Show changes
271
+ console.log(`${LOG_PREFIX} Changes to apply (${changes.length}):`);
272
+ for (const change of changes) {
273
+ const icon = change.type === 'sync' ? '↻' : '→';
274
+ console.log(` ${icon} ${change.wuId} ${change.type} ${change.initiative}`);
275
+ }
276
+ if (!applyMode) {
277
+ console.log('');
278
+ console.log(`${LOG_PREFIX} Dry-run complete. Use --apply to write changes.`);
279
+ console.log(`${LOG_PREFIX} ${ADMIN_ENV_VAR}=1 pnpm initiative:bulk-assign --apply`);
280
+ return;
281
+ }
282
+ // Apply changes via micro-worktree
283
+ console.log('');
284
+ console.log(`${LOG_PREFIX} Applying changes via micro-worktree...`);
285
+ await withMicroWorktree({
286
+ operation: OPERATION_NAME,
287
+ id: `bulk-${Date.now()}`,
288
+ logPrefix: LOG_PREFIX,
289
+ execute: async ({ worktreePath }) => {
290
+ const filesChanged = [];
291
+ for (const change of changes) {
292
+ if (!change.newContent)
293
+ continue;
294
+ // Calculate relative path from repo root
295
+ const relativePath = change.filePath.replace(process.cwd() + '/', '');
296
+ const worktreeFilePath = join(worktreePath, relativePath);
297
+ await writeFile(worktreeFilePath, change.newContent, { encoding: 'utf-8' });
298
+ filesChanged.push(relativePath);
299
+ }
300
+ const commitMessage = `chore: bulk-assign ${changes.length} WUs to initiatives\n\nAuto-assigned by initiative-bulk-assign-wus`;
301
+ return {
302
+ commitMessage,
303
+ files: filesChanged,
304
+ };
305
+ },
306
+ });
307
+ console.log(`${LOG_PREFIX} ✅ Successfully applied ${changes.length} changes`);
308
+ }
309
+ // Guard main() for testability (WU-1366)
310
+ import { fileURLToPath } from 'node:url';
311
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
312
+ main().catch((err) => {
313
+ die(`Bulk assign failed: ${err.message}`);
314
+ });
315
+ }
@@ -0,0 +1,314 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Metrics Snapshot Capture CLI (WU-1018)
4
+ *
5
+ * Captures DORA metrics, lane health, and flow state snapshots.
6
+ *
7
+ * Usage:
8
+ * pnpm metrics:snapshot # All metrics, JSON output
9
+ * pnpm metrics:snapshot --type dora # DORA metrics only
10
+ * pnpm metrics:snapshot --type lanes # Lane health only
11
+ * pnpm metrics:snapshot --type flow # Flow state only
12
+ * pnpm metrics:snapshot --dry-run # Preview without writing
13
+ * pnpm metrics:snapshot --output metrics.json
14
+ *
15
+ * @module metrics-snapshot
16
+ * @see {@link @lumenflow/metrics/flow/capture-metrics-snapshot}
17
+ */
18
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
19
+ import { existsSync } from 'node:fs';
20
+ import { join, dirname } from 'node:path';
21
+ import fg from 'fast-glob';
22
+ import { parse as parseYaml } from 'yaml';
23
+ import { Command } from 'commander';
24
+ import { captureMetricsSnapshot, } from '@lumenflow/metrics';
25
+ import { getGitForCwd } from '@lumenflow/core/dist/git-adapter.js';
26
+ import { die } from '@lumenflow/core/dist/error-handler.js';
27
+ /** Log prefix for console output */
28
+ const LOG_PREFIX = '[metrics:snapshot]';
29
+ /** Default snapshot output path */
30
+ const DEFAULT_OUTPUT = '.beacon/snapshots/metrics-latest.json';
31
+ /** WU directory relative to repo root */
32
+ const WU_DIR = 'docs/04-operations/tasks/wu';
33
+ /** Skip-gates audit file path */
34
+ const SKIP_GATES_PATH = '.beacon/skip-gates-audit.ndjson';
35
+ /** Snapshot type options */
36
+ const SNAPSHOT_TYPES = ['all', 'dora', 'lanes', 'flow'];
37
+ /**
38
+ * Parse command line arguments
39
+ */
40
+ function parseArgs() {
41
+ const program = new Command()
42
+ .name('metrics-snapshot')
43
+ .description('Capture DORA metrics, lane health, and flow state snapshot')
44
+ .option('--type <type>', `Snapshot type: ${SNAPSHOT_TYPES.join(', ')} (default: all)`, 'all')
45
+ .option('--days <number>', 'Days to analyze for DORA metrics (default: 7)', '7')
46
+ .option('--output <path>', `Output file path (default: ${DEFAULT_OUTPUT})`, DEFAULT_OUTPUT)
47
+ .option('--dry-run', 'Preview snapshot without writing to file')
48
+ .exitOverride();
49
+ try {
50
+ program.parse(process.argv);
51
+ return program.opts();
52
+ }
53
+ catch (err) {
54
+ const error = err;
55
+ if (error.code === 'commander.helpDisplayed' || error.code === 'commander.version') {
56
+ process.exit(0);
57
+ }
58
+ throw err;
59
+ }
60
+ }
61
+ /**
62
+ * Calculate week date range
63
+ */
64
+ function calculateWeekRange(days) {
65
+ const weekEnd = new Date();
66
+ weekEnd.setHours(23, 59, 59, 999);
67
+ const weekStart = new Date(weekEnd);
68
+ weekStart.setDate(weekStart.getDate() - days);
69
+ weekStart.setHours(0, 0, 0, 0);
70
+ return { weekStart, weekEnd };
71
+ }
72
+ /**
73
+ * Load WU metrics from YAML files
74
+ */
75
+ async function loadWUMetrics(baseDir) {
76
+ const wuDir = join(baseDir, WU_DIR);
77
+ const wuFiles = await fg('WU-*.yaml', { cwd: wuDir, absolute: true });
78
+ const wuMetrics = [];
79
+ for (const file of wuFiles) {
80
+ try {
81
+ const content = await readFile(file, { encoding: 'utf-8' });
82
+ const wu = parseYaml(content);
83
+ // Map WU status to valid WUMetrics status
84
+ const rawStatus = wu.status;
85
+ let status = 'ready';
86
+ if (rawStatus === 'in_progress')
87
+ status = 'in_progress';
88
+ else if (rawStatus === 'blocked')
89
+ status = 'blocked';
90
+ else if (rawStatus === 'waiting')
91
+ status = 'waiting';
92
+ else if (rawStatus === 'done')
93
+ status = 'done';
94
+ else if (rawStatus === 'ready')
95
+ status = 'ready';
96
+ wuMetrics.push({
97
+ id: wu.id,
98
+ title: wu.title,
99
+ lane: wu.lane,
100
+ status,
101
+ priority: wu.priority,
102
+ claimedAt: wu.claimed_at ? new Date(wu.claimed_at) : undefined,
103
+ completedAt: wu.completed_at ? new Date(wu.completed_at) : undefined,
104
+ cycleTimeHours: calculateCycleTime(wu),
105
+ });
106
+ }
107
+ catch {
108
+ // Skip invalid WU files
109
+ }
110
+ }
111
+ return wuMetrics;
112
+ }
113
+ /**
114
+ * Calculate cycle time in hours from WU data
115
+ */
116
+ function calculateCycleTime(wu) {
117
+ if (!wu.claimed_at || !wu.completed_at) {
118
+ return undefined;
119
+ }
120
+ const claimed = new Date(wu.claimed_at);
121
+ const completed = new Date(wu.completed_at);
122
+ const diffMs = completed.getTime() - claimed.getTime();
123
+ const diffHours = diffMs / (1000 * 60 * 60);
124
+ return Math.round(diffHours * 10) / 10;
125
+ }
126
+ /**
127
+ * Load git commits from repository
128
+ */
129
+ async function loadGitCommits(weekStart, weekEnd) {
130
+ try {
131
+ const git = getGitForCwd();
132
+ // simple-git log() returns an object with 'all' array containing commits
133
+ // The actual simple-git result includes date but the adapter type is narrowed
134
+ const logResult = await git.log({ maxCount: 500 });
135
+ const commits = [];
136
+ for (const entry of [...logResult.all]) {
137
+ // Filter by date range
138
+ const commitDate = new Date(entry.date);
139
+ if (commitDate < weekStart || commitDate > weekEnd) {
140
+ continue;
141
+ }
142
+ const message = entry.message;
143
+ // Extract WU ID from commit message if present
144
+ const wuIdMatch = message.match(/\b(WU-\d+)\b/i);
145
+ const wuId = wuIdMatch ? wuIdMatch[1].toUpperCase() : undefined;
146
+ // Determine commit type from conventional commit prefix
147
+ const typeMatch = message.match(/^(feat|fix|docs|chore|refactor|test|style|perf|ci)[\(:]?/i);
148
+ const type = typeMatch ? typeMatch[1].toLowerCase() : undefined;
149
+ commits.push({
150
+ hash: entry.hash,
151
+ timestamp: commitDate,
152
+ message,
153
+ type,
154
+ wuId,
155
+ });
156
+ }
157
+ return commits;
158
+ }
159
+ catch (err) {
160
+ console.warn(`${LOG_PREFIX} ⚠️ Could not load git commits: ${err.message}`);
161
+ return [];
162
+ }
163
+ }
164
+ /**
165
+ * Load skip-gates audit entries
166
+ */
167
+ async function loadSkipGatesEntries(baseDir) {
168
+ const auditPath = join(baseDir, SKIP_GATES_PATH);
169
+ if (!existsSync(auditPath)) {
170
+ return [];
171
+ }
172
+ try {
173
+ const content = await readFile(auditPath, { encoding: 'utf-8' });
174
+ const lines = content.split('\n').filter((line) => line.trim());
175
+ const entries = [];
176
+ for (const line of lines) {
177
+ try {
178
+ const raw = JSON.parse(line);
179
+ if (raw.timestamp && raw.wu_id && raw.reason && raw.gate) {
180
+ entries.push({
181
+ timestamp: new Date(raw.timestamp),
182
+ wuId: raw.wu_id,
183
+ reason: raw.reason,
184
+ gate: raw.gate,
185
+ });
186
+ }
187
+ }
188
+ catch {
189
+ // Skip invalid JSON lines
190
+ }
191
+ }
192
+ return entries;
193
+ }
194
+ catch {
195
+ return [];
196
+ }
197
+ }
198
+ /**
199
+ * Format snapshot for display
200
+ */
201
+ function formatSnapshot(snapshot, type) {
202
+ const lines = [];
203
+ lines.push('═══════════════════════════════════════════════════════════════');
204
+ lines.push(` METRICS SNAPSHOT (type: ${type})`);
205
+ lines.push(` Generated: ${new Date().toISOString()}`);
206
+ lines.push('═══════════════════════════════════════════════════════════════');
207
+ lines.push('');
208
+ if (snapshot.dora) {
209
+ lines.push('┌─────────────────────────────────────────────────────────────┐');
210
+ lines.push('│ DORA METRICS │');
211
+ lines.push('├─────────────────────────────────────────────────────────────┤');
212
+ const { deploymentFrequency, leadTimeForChanges, changeFailureRate, meanTimeToRecovery } = snapshot.dora;
213
+ lines.push(`│ Deployment Frequency: ${deploymentFrequency.deploysPerWeek}/week (${deploymentFrequency.status})`);
214
+ lines.push(`│ Lead Time: ${leadTimeForChanges.medianHours}h median (${leadTimeForChanges.status})`);
215
+ lines.push(`│ Change Failure Rate: ${changeFailureRate.failurePercentage}% (${changeFailureRate.status})`);
216
+ lines.push(`│ MTTR: ${meanTimeToRecovery.averageHours}h (${meanTimeToRecovery.status})`);
217
+ lines.push('└─────────────────────────────────────────────────────────────┘');
218
+ lines.push('');
219
+ }
220
+ if (snapshot.lanes) {
221
+ lines.push('┌─────────────────────────────────────────────────────────────┐');
222
+ lines.push('│ LANE HEALTH │');
223
+ lines.push('├─────────────────────────────────────────────────────────────┤');
224
+ lines.push(`│ Total Active: ${snapshot.lanes.totalActive} | Blocked: ${snapshot.lanes.totalBlocked} | Completed: ${snapshot.lanes.totalCompleted}`);
225
+ lines.push('│');
226
+ for (const lane of snapshot.lanes.lanes) {
227
+ const statusIcon = lane.status === 'healthy' ? '✓' : lane.status === 'at-risk' ? '⚠' : '✗';
228
+ lines.push(`│ ${statusIcon} ${lane.lane.padEnd(20)} ${lane.wusCompleted} done, ${lane.wusInProgress} active, ${lane.wusBlocked} blocked`);
229
+ }
230
+ lines.push('└─────────────────────────────────────────────────────────────┘');
231
+ lines.push('');
232
+ }
233
+ if (snapshot.flow) {
234
+ lines.push('┌─────────────────────────────────────────────────────────────┐');
235
+ lines.push('│ FLOW STATE │');
236
+ lines.push('├─────────────────────────────────────────────────────────────┤');
237
+ lines.push(`│ Ready: ${snapshot.flow.ready} | In Progress: ${snapshot.flow.inProgress}`);
238
+ lines.push(`│ Blocked: ${snapshot.flow.blocked} | Waiting: ${snapshot.flow.waiting}`);
239
+ lines.push(`│ Done: ${snapshot.flow.done} | Total Active: ${snapshot.flow.totalActive}`);
240
+ lines.push('└─────────────────────────────────────────────────────────────┘');
241
+ }
242
+ return lines.join('\n');
243
+ }
244
+ /**
245
+ * Main function
246
+ */
247
+ async function main() {
248
+ const opts = parseArgs();
249
+ const baseDir = process.cwd();
250
+ const type = opts.type;
251
+ const days = parseInt(opts.days, 10);
252
+ // Validate snapshot type
253
+ if (!SNAPSHOT_TYPES.includes(type)) {
254
+ die(`Invalid snapshot type: ${type}\n\nValid types: ${SNAPSHOT_TYPES.join(', ')}`);
255
+ }
256
+ console.log(`${LOG_PREFIX} Capturing ${type} metrics snapshot...`);
257
+ const { weekStart, weekEnd } = calculateWeekRange(days);
258
+ console.log(`${LOG_PREFIX} Date range: ${weekStart.toISOString().split('T')[0]} to ${weekEnd.toISOString().split('T')[0]}`);
259
+ // Load data
260
+ console.log(`${LOG_PREFIX} Loading WU data...`);
261
+ const wuMetrics = await loadWUMetrics(baseDir);
262
+ console.log(`${LOG_PREFIX} Found ${wuMetrics.length} WUs`);
263
+ console.log(`${LOG_PREFIX} Loading git commits...`);
264
+ const commits = await loadGitCommits(weekStart, weekEnd);
265
+ console.log(`${LOG_PREFIX} Found ${commits.length} commits`);
266
+ console.log(`${LOG_PREFIX} Loading skip-gates audit entries...`);
267
+ const skipGatesEntries = await loadSkipGatesEntries(baseDir);
268
+ console.log(`${LOG_PREFIX} Found ${skipGatesEntries.length} skip-gates entries`);
269
+ // Capture snapshot
270
+ const input = {
271
+ commits,
272
+ wuMetrics,
273
+ skipGatesEntries,
274
+ weekStart,
275
+ weekEnd,
276
+ type,
277
+ };
278
+ const snapshot = captureMetricsSnapshot(input);
279
+ // Output
280
+ console.log('');
281
+ console.log(formatSnapshot(snapshot, type));
282
+ console.log('');
283
+ // Write to file (unless dry-run)
284
+ if (opts.dryRun) {
285
+ console.log(`${LOG_PREFIX} Dry run - not writing to file.`);
286
+ console.log(`${LOG_PREFIX} Would write to: ${opts.output}`);
287
+ }
288
+ else {
289
+ const outputPath = join(baseDir, opts.output);
290
+ const outputDir = dirname(outputPath);
291
+ // Ensure directory exists
292
+ if (!existsSync(outputDir)) {
293
+ await mkdir(outputDir, { recursive: true });
294
+ }
295
+ const outputData = {
296
+ capturedAt: new Date().toISOString(),
297
+ type,
298
+ dateRange: {
299
+ start: weekStart.toISOString(),
300
+ end: weekEnd.toISOString(),
301
+ },
302
+ snapshot,
303
+ };
304
+ await writeFile(outputPath, JSON.stringify(outputData, null, 2), { encoding: 'utf-8' });
305
+ console.log(`${LOG_PREFIX} ✅ Snapshot written to: ${outputPath}`);
306
+ }
307
+ }
308
+ // Guard main() for testability (WU-1366)
309
+ import { fileURLToPath } from 'node:url';
310
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
311
+ main().catch((err) => {
312
+ die(`Metrics snapshot failed: ${err.message}`);
313
+ });
314
+ }
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Orchestrate Initiative Status CLI
4
+ *
5
+ * Compact status view for initiative orchestration.
6
+ * Shows progress of WUs in an initiative.
7
+ *
8
+ * Usage:
9
+ * pnpm orchestrate:init-status --initiative INIT-001
10
+ */
11
+ import { Command } from 'commander';
12
+ import { existsSync, readdirSync } from 'node:fs';
13
+ import { loadInitiativeWUs, calculateProgress, formatProgress } from '@lumenflow/initiatives';
14
+ import { EXIT_CODES } from '@lumenflow/core/dist/wu-constants.js';
15
+ import chalk from 'chalk';
16
+ const LOG_PREFIX = '[orchestrate:init-status]';
17
+ const STAMPS_DIR = '.beacon/stamps';
18
+ function getCompletedWUs(wuIds) {
19
+ const completed = new Set();
20
+ if (!existsSync(STAMPS_DIR)) {
21
+ return completed;
22
+ }
23
+ const files = readdirSync(STAMPS_DIR);
24
+ for (const wuId of wuIds) {
25
+ if (files.includes(`${wuId}.done`)) {
26
+ completed.add(wuId);
27
+ }
28
+ }
29
+ return completed;
30
+ }
31
+ const program = new Command()
32
+ .name('orchestrate:init-status')
33
+ .description('Show initiative progress status')
34
+ .requiredOption('-i, --initiative <id>', 'Initiative ID (e.g., INIT-001)')
35
+ .action(async (opts) => {
36
+ try {
37
+ console.log(chalk.cyan(`${LOG_PREFIX} Loading initiative ${opts.initiative}...`));
38
+ const { initiative, wus } = loadInitiativeWUs(opts.initiative);
39
+ console.log(chalk.bold(`\nInitiative: ${initiative.id} - ${initiative.title}`));
40
+ console.log('');
41
+ const progress = calculateProgress(wus);
42
+ console.log(chalk.bold('Progress:'));
43
+ console.log(formatProgress(progress));
44
+ console.log('');
45
+ // Show WU status breakdown
46
+ const completed = getCompletedWUs(wus.map((w) => w.id));
47
+ console.log(chalk.bold('WUs:'));
48
+ for (const wu of wus) {
49
+ const status = completed.has(wu.id)
50
+ ? chalk.green('✓ done')
51
+ : wu.doc.status === 'in_progress'
52
+ ? chalk.yellow('⟳ in_progress')
53
+ : wu.doc.status === 'blocked'
54
+ ? chalk.red('⛔ blocked')
55
+ : chalk.gray('○ ready');
56
+ console.log(` ${wu.id}: ${wu.doc.title} [${status}]`);
57
+ }
58
+ }
59
+ catch (err) {
60
+ console.error(chalk.red(`${LOG_PREFIX} Error: ${err.message}`));
61
+ process.exit(EXIT_CODES.ERROR);
62
+ }
63
+ });
64
+ program.parse();