@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.
- package/dist/__tests__/flow-report.test.js +24 -0
- package/dist/__tests__/metrics-snapshot.test.js +24 -0
- package/dist/agent-issues-query.js +251 -0
- package/dist/agent-log-issue.js +67 -0
- package/dist/agent-session-end.js +36 -0
- package/dist/agent-session.js +46 -0
- package/dist/flow-bottlenecks.js +183 -0
- package/dist/flow-report.js +311 -0
- package/dist/gates.js +24 -10
- package/dist/init.js +251 -0
- package/dist/initiative-bulk-assign-wus.js +315 -0
- package/dist/metrics-snapshot.js +314 -0
- package/dist/orchestrate-init-status.js +64 -0
- package/dist/orchestrate-initiative.js +100 -0
- package/dist/orchestrate-monitor.js +90 -0
- package/dist/wu-claim.js +18 -7
- package/dist/wu-delete.js +241 -0
- package/dist/wu-done.js +102 -10
- package/dist/wu-unlock-lane.js +158 -0
- package/package.json +24 -4
|
@@ -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();
|