@howlil/ez-agents 3.4.2 → 3.5.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 (74) hide show
  1. package/README.md +77 -2
  2. package/agents/ez-observer-agent.md +260 -0
  3. package/agents/ez-release-agent.md +333 -0
  4. package/agents/ez-requirements-agent.md +377 -0
  5. package/agents/ez-scrum-master-agent.md +242 -0
  6. package/agents/ez-tech-lead-agent.md +267 -0
  7. package/bin/install.js +3221 -3272
  8. package/commands/ez/arch-review.md +102 -0
  9. package/commands/ez/execute-phase.md +11 -0
  10. package/commands/ez/export-session.md +79 -0
  11. package/commands/ez/gather-requirements.md +117 -0
  12. package/commands/ez/git-workflow.md +72 -0
  13. package/commands/ez/hotfix.md +120 -0
  14. package/commands/ez/import-session.md +82 -0
  15. package/commands/ez/list-sessions.md +96 -0
  16. package/commands/ez/package-manager.md +316 -0
  17. package/commands/ez/plan-phase.md +9 -1
  18. package/commands/ez/preflight.md +79 -0
  19. package/commands/ez/progress.md +13 -1
  20. package/commands/ez/release.md +153 -0
  21. package/commands/ez/resume.md +107 -0
  22. package/commands/ez/standup.md +85 -0
  23. package/ez-agents/bin/ez-tools.cjs +1095 -716
  24. package/ez-agents/bin/lib/bdd-validator.cjs +622 -0
  25. package/ez-agents/bin/lib/content-scanner.cjs +238 -0
  26. package/ez-agents/bin/lib/context-cache.cjs +154 -0
  27. package/ez-agents/bin/lib/context-errors.cjs +71 -0
  28. package/ez-agents/bin/lib/context-manager.cjs +220 -0
  29. package/ez-agents/bin/lib/discussion-synthesizer.cjs +458 -0
  30. package/ez-agents/bin/lib/file-access.cjs +207 -0
  31. package/ez-agents/bin/lib/git-errors.cjs +83 -0
  32. package/ez-agents/bin/lib/git-utils.cjs +321 -203
  33. package/ez-agents/bin/lib/git-workflow-engine.cjs +1157 -0
  34. package/ez-agents/bin/lib/index.cjs +46 -2
  35. package/ez-agents/bin/lib/lockfile-validator.cjs +227 -0
  36. package/ez-agents/bin/lib/logger.cjs +124 -154
  37. package/ez-agents/bin/lib/memory-compression.cjs +256 -0
  38. package/ez-agents/bin/lib/metrics-tracker.cjs +406 -0
  39. package/ez-agents/bin/lib/package-manager-detector.cjs +203 -0
  40. package/ez-agents/bin/lib/package-manager-executor.cjs +385 -0
  41. package/ez-agents/bin/lib/package-manager-service.cjs +216 -0
  42. package/ez-agents/bin/lib/release-validator.cjs +614 -0
  43. package/ez-agents/bin/lib/safe-exec.cjs +128 -214
  44. package/ez-agents/bin/lib/session-chain.cjs +304 -0
  45. package/ez-agents/bin/lib/session-errors.cjs +81 -0
  46. package/ez-agents/bin/lib/session-export.cjs +251 -0
  47. package/ez-agents/bin/lib/session-import.cjs +262 -0
  48. package/ez-agents/bin/lib/session-manager.cjs +280 -0
  49. package/ez-agents/bin/lib/tier-manager.cjs +428 -0
  50. package/ez-agents/bin/lib/url-fetch.cjs +170 -0
  51. package/ez-agents/references/metrics-schema.md +118 -0
  52. package/ez-agents/references/planning-config.md +140 -0
  53. package/ez-agents/references/tier-strategy.md +103 -0
  54. package/ez-agents/templates/bdd-feature.md +173 -0
  55. package/ez-agents/templates/discussion.md +68 -0
  56. package/ez-agents/templates/incident-runbook.md +205 -0
  57. package/ez-agents/templates/release-checklist.md +133 -0
  58. package/ez-agents/templates/rollback-plan.md +201 -0
  59. package/ez-agents/workflows/arch-review.md +54 -0
  60. package/ez-agents/workflows/autonomous.md +844 -743
  61. package/ez-agents/workflows/execute-phase.md +45 -0
  62. package/ez-agents/workflows/export-session.md +255 -0
  63. package/ez-agents/workflows/gather-requirements.md +206 -0
  64. package/ez-agents/workflows/help.md +92 -0
  65. package/ez-agents/workflows/hotfix.md +291 -0
  66. package/ez-agents/workflows/import-session.md +303 -0
  67. package/ez-agents/workflows/new-milestone.md +713 -384
  68. package/ez-agents/workflows/new-project.md +1107 -1113
  69. package/ez-agents/workflows/plan-phase.md +22 -0
  70. package/ez-agents/workflows/progress.md +15 -25
  71. package/ez-agents/workflows/release.md +253 -0
  72. package/ez-agents/workflows/resume-session.md +215 -0
  73. package/ez-agents/workflows/standup.md +64 -0
  74. package/package.json +9 -2
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Memory Compression — Compress long session transcripts
5
+ *
6
+ * Reduces session size by keeping first N and last M messages
7
+ */
8
+
9
+ const { defaultLogger: logger } = require('./logger.cjs');
10
+
11
+ class MemoryCompression {
12
+ /**
13
+ * Create a MemoryCompression instance
14
+ * @param {Object} sessionManager - SessionManager instance
15
+ */
16
+ constructor(sessionManager) {
17
+ this.sessionManager = sessionManager;
18
+ }
19
+
20
+ /**
21
+ * Compress a session transcript
22
+ * @param {string} sessionId - Session ID
23
+ * @param {Object} options - Compression options
24
+ * @param {number} [options.threshold=50] - Minimum messages before compression
25
+ * @param {number} [options.keepFirst=5] - Messages to keep at start
26
+ * @param {number} [options.keepLast=10] - Messages to keep at end
27
+ * @returns {Object} Compression result
28
+ */
29
+ compress(sessionId, options = {}) {
30
+ const session = this.sessionManager.loadSession(sessionId);
31
+ if (!session) {
32
+ return { compressed: false, reason: 'Session not found' };
33
+ }
34
+
35
+ const {
36
+ threshold = 50,
37
+ keepFirst = 5,
38
+ keepLast = 10
39
+ } = options;
40
+
41
+ const transcript = session.context?.transcript || '';
42
+
43
+ // Handle string transcript (split by newlines or messages)
44
+ let messages = [];
45
+ if (typeof transcript === 'string') {
46
+ // Try to parse as JSON array first
47
+ try {
48
+ messages = JSON.parse(transcript);
49
+ } catch {
50
+ // Split by newlines as fallback
51
+ messages = transcript.split('\n').filter(line => line.trim());
52
+ }
53
+ } else if (Array.isArray(transcript)) {
54
+ messages = transcript;
55
+ }
56
+
57
+ if (messages.length <= threshold) {
58
+ logger.info('Session below compression threshold', { sessionId, messageCount: messages.length });
59
+ return {
60
+ compressed: false,
61
+ reason: 'Below threshold',
62
+ messageCount: messages.length,
63
+ threshold
64
+ };
65
+ }
66
+
67
+ // Create compressed transcript
68
+ const firstMessages = messages.slice(0, keepFirst);
69
+ const lastMessages = messages.slice(-keepLast);
70
+ const compressedCount = messages.length - keepFirst - keepLast;
71
+
72
+ const placeholder = {
73
+ role: 'system',
74
+ content: `... ${compressedCount} messages compressed ...`,
75
+ timestamp: new Date().toISOString(),
76
+ compressed: true
77
+ };
78
+
79
+ const compressedMessages = [
80
+ ...firstMessages,
81
+ placeholder,
82
+ ...lastMessages
83
+ ];
84
+
85
+ // Update session
86
+ const updates = {
87
+ context: {
88
+ transcript: compressedMessages
89
+ },
90
+ metadata: {
91
+ compressed: true,
92
+ compressed_at: new Date().toISOString(),
93
+ compression_stats: {
94
+ original_count: messages.length,
95
+ compressed_count: compressedMessages.length,
96
+ removed_count: compressedCount
97
+ }
98
+ }
99
+ };
100
+
101
+ this.sessionManager.updateSession(sessionId, updates);
102
+
103
+ const reduction = Math.round((1 - compressedMessages.length / messages.length) * 100);
104
+
105
+ logger.info('Session compressed', {
106
+ sessionId,
107
+ originalLength: messages.length,
108
+ newLength: compressedMessages.length,
109
+ reduction
110
+ });
111
+
112
+ return {
113
+ compressed: true,
114
+ originalLength: messages.length,
115
+ newLength: compressedMessages.length,
116
+ reduction
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Get compression stats for a session
122
+ * @param {string} sessionId - Session ID
123
+ * @returns {Object} Compression statistics
124
+ */
125
+ getCompressionStats(sessionId) {
126
+ const session = this.sessionManager.loadSession(sessionId);
127
+ if (!session) {
128
+ return { compressed: false, reason: 'Session not found' };
129
+ }
130
+
131
+ if (!session.metadata?.compressed) {
132
+ return { compressed: false };
133
+ }
134
+
135
+ const originalSize = session.metadata.compression_stats?.original_count || 0;
136
+ const compressedSize = session.metadata.compression_stats?.compressed_count || 0;
137
+ const reduction = session.metadata.compression_stats?.removed_count || 0;
138
+ const reductionPercent = originalSize > 0
139
+ ? Math.round((reduction / originalSize) * 100)
140
+ : 0;
141
+
142
+ return {
143
+ compressed: true,
144
+ original_size: originalSize,
145
+ compressed_size: compressedSize,
146
+ reduction_percent: reductionPercent,
147
+ compressed_at: session.metadata.compressed_at
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Check if session should be compressed
153
+ * @param {string} sessionId - Session ID
154
+ * @param {number} threshold - Message threshold
155
+ * @returns {boolean} True if compression recommended
156
+ */
157
+ shouldCompress(sessionId, threshold = 50) {
158
+ const session = this.sessionManager.loadSession(sessionId);
159
+ if (!session) {
160
+ return false;
161
+ }
162
+
163
+ const transcript = session.context?.transcript || '';
164
+ let messageCount = 0;
165
+
166
+ if (typeof transcript === 'string') {
167
+ try {
168
+ const messages = JSON.parse(transcript);
169
+ messageCount = Array.isArray(messages) ? messages.length : 0;
170
+ } catch {
171
+ messageCount = transcript.split('\n').filter(line => line.trim()).length;
172
+ }
173
+ } else if (Array.isArray(transcript)) {
174
+ messageCount = transcript.length;
175
+ }
176
+
177
+ return messageCount > threshold;
178
+ }
179
+
180
+ /**
181
+ * Compress all sessions exceeding threshold
182
+ * @param {Object} options - Compression options
183
+ * @returns {Object} Batch compression result
184
+ */
185
+ compressAll(options = {}) {
186
+ const sessions = this.sessionManager.listSessions();
187
+ const results = {
188
+ total: sessions.length,
189
+ compressed: 0,
190
+ skipped: 0,
191
+ details: []
192
+ };
193
+
194
+ for (const sessionMeta of sessions) {
195
+ const shouldCompress = this.shouldCompress(sessionMeta.session_id, options.threshold);
196
+
197
+ if (shouldCompress) {
198
+ const result = this.compress(sessionMeta.session_id, options);
199
+ if (result.compressed) {
200
+ results.compressed++;
201
+ results.details.push({
202
+ sessionId: sessionMeta.session_id,
203
+ ...result
204
+ });
205
+ } else {
206
+ results.skipped++;
207
+ }
208
+ } else {
209
+ results.skipped++;
210
+ }
211
+ }
212
+
213
+ logger.info('Batch compression complete', {
214
+ total: results.total,
215
+ compressed: results.compressed,
216
+ skipped: results.skipped
217
+ });
218
+
219
+ return results;
220
+ }
221
+
222
+ /**
223
+ * Decompress a session (restore placeholder info)
224
+ * Note: Cannot restore original messages, only marks as decompressed
225
+ * @param {string} sessionId - Session ID
226
+ * @returns {Object} Decompression result
227
+ */
228
+ decompress(sessionId) {
229
+ const session = this.sessionManager.loadSession(sessionId);
230
+ if (!session) {
231
+ return { decompressed: false, reason: 'Session not found' };
232
+ }
233
+
234
+ if (!session.metadata?.compressed) {
235
+ return { decompressed: false, reason: 'Not compressed' };
236
+ }
237
+
238
+ // Remove compression metadata
239
+ this.sessionManager.updateSession(sessionId, {
240
+ metadata: {
241
+ compressed: false,
242
+ compressed_at: null,
243
+ compression_stats: null
244
+ }
245
+ });
246
+
247
+ logger.info('Session marked as decompressed', { sessionId });
248
+
249
+ return {
250
+ decompressed: true,
251
+ note: 'Original messages cannot be restored'
252
+ };
253
+ }
254
+ }
255
+
256
+ module.exports = MemoryCompression;
@@ -0,0 +1,406 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Metrics Tracker — Records and queries EZ Agents success metrics
5
+ *
6
+ * Manages .planning/metrics.json: phase velocity, BDD pass rate,
7
+ * defect density, token cost, and DORA-inspired metrics.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { withLock } = require('./file-lock.cjs');
15
+
16
+ const METRICS_PATH = '.planning/metrics.json';
17
+
18
+ // ─────────────────────────────────────────────
19
+ // Metric Thresholds
20
+ // ─────────────────────────────────────────────
21
+
22
+ const METRIC_THRESHOLDS = {
23
+ deviation_rate: { warn: 0.2, bad: 0.4 }, // DORA: change failure rate
24
+ avg_velocity_min: { warn: 120, bad: 240 }, // warn if avg > 2h, bad if > 4h
25
+ bdd_pass_rate: { warn: 0.8, bad: 0.6 }, // MVP: 60%, Medium: 80%
26
+ test_coverage: { warn: 60, bad: 40 } // warn below 60%
27
+ };
28
+
29
+ function getThresholdStatus(value, thresholds, lowerIsBetter = false) {
30
+ if (lowerIsBetter) {
31
+ if (value >= thresholds.bad) return '🔴';
32
+ if (value >= thresholds.warn) return '🟡';
33
+ return '🟢';
34
+ } else {
35
+ if (value <= thresholds.bad) return '🔴';
36
+ if (value <= thresholds.warn) return '🟡';
37
+ return '🟢';
38
+ }
39
+ }
40
+
41
+ // ─────────────────────────────────────────────
42
+ // Schema defaults
43
+ // ─────────────────────────────────────────────
44
+
45
+ function defaultMetrics(project) {
46
+ return {
47
+ schema_version: '1.0',
48
+ project: project || 'unknown',
49
+ updated: new Date().toISOString(),
50
+ phase_metrics: [],
51
+ project_metrics: {
52
+ requirements_coverage_pct: 0,
53
+ test_coverage_pct: 0,
54
+ bdd_scenarios_total: 0,
55
+ bdd_scenarios_passing: 0,
56
+ bdd_scenarios_must: 0,
57
+ bdd_scenarios_must_passing: 0
58
+ },
59
+ agent_metrics: {
60
+ total_token_cost_usd: 0,
61
+ avg_cost_per_plan: 0,
62
+ deviation_rate: 0,
63
+ avg_plans_per_phase: 0,
64
+ avg_velocity_min_per_plan: 0
65
+ },
66
+ business_metrics: {
67
+ time_to_first_ship_days: null,
68
+ hotfixes_deployed: 0,
69
+ milestones_shipped: 0,
70
+ current_tier: 'mvp',
71
+ phases_total: 0,
72
+ phases_completed: 0
73
+ }
74
+ };
75
+ }
76
+
77
+ // ─────────────────────────────────────────────
78
+ // Load / Save
79
+ // ─────────────────────────────────────────────
80
+
81
+ function loadMetrics(metricsPath = METRICS_PATH) {
82
+ const fullPath = path.resolve(process.cwd(), metricsPath);
83
+ if (!fs.existsSync(fullPath)) {
84
+ return defaultMetrics();
85
+ }
86
+ try {
87
+ return JSON.parse(fs.readFileSync(fullPath, 'utf8'));
88
+ } catch {
89
+ return defaultMetrics();
90
+ }
91
+ }
92
+
93
+ function saveMetrics(metrics, metricsPath = METRICS_PATH) {
94
+ const fullPath = path.resolve(process.cwd(), metricsPath);
95
+ const dir = path.dirname(fullPath);
96
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
97
+ metrics.updated = new Date().toISOString();
98
+ fs.writeFileSync(fullPath, JSON.stringify(metrics, null, 2) + '\n', 'utf8');
99
+ }
100
+
101
+ async function saveMetricsLocked(metrics, metricsPath = METRICS_PATH) {
102
+ const fullPath = path.resolve(process.cwd(), metricsPath);
103
+ return withLock(fullPath, async () => {
104
+ saveMetrics(metrics, metricsPath);
105
+ });
106
+ }
107
+
108
+ // ─────────────────────────────────────────────
109
+ // Phase Metrics
110
+ // ─────────────────────────────────────────────
111
+
112
+ /**
113
+ * Record phase execution metrics (called by ez-executor after plan completion)
114
+ * @param {object} data - { phase, phase_name, plans_total, plans_completed, velocity_min, deviation_count, tasks_total, cost_usd }
115
+ */
116
+ async function recordPlanMetrics(data, metricsPath = METRICS_PATH) {
117
+ const fullPath = path.resolve(process.cwd(), metricsPath);
118
+ return withLock(fullPath, async () => {
119
+ const metrics = loadMetrics(metricsPath); // read INSIDE lock
120
+
121
+ const existing = metrics.phase_metrics.find(m => m.phase === data.phase);
122
+ if (existing) {
123
+ // Update existing phase entry
124
+ Object.assign(existing, {
125
+ plans_completed: data.plans_completed || existing.plans_completed,
126
+ velocity_min: data.velocity_min || existing.velocity_min,
127
+ deviation_count: (existing.deviation_count || 0) + (data.deviation_count || 0),
128
+ defect_density: data.tasks_total > 0
129
+ ? ((existing.deviation_count || 0) + (data.deviation_count || 0)) / data.tasks_total
130
+ : existing.defect_density
131
+ });
132
+ } else {
133
+ metrics.phase_metrics.push({
134
+ phase: data.phase,
135
+ phase_name: data.phase_name || `phase-${data.phase}`,
136
+ plans_total: data.plans_total || 0,
137
+ plans_completed: data.plans_completed || 0,
138
+ velocity_min: data.velocity_min || 0,
139
+ defect_density: data.tasks_total > 0 ? (data.deviation_count || 0) / data.tasks_total : 0,
140
+ bdd_pass_rate: null, // filled by verifier
141
+ bdd_must_passing: null,
142
+ bdd_must_total: null,
143
+ deviation_count: data.deviation_count || 0,
144
+ completed_at: new Date().toISOString()
145
+ });
146
+ }
147
+
148
+ // Update agent metrics
149
+ if (data.cost_usd) {
150
+ metrics.agent_metrics.total_token_cost_usd =
151
+ (metrics.agent_metrics.total_token_cost_usd || 0) + data.cost_usd;
152
+ }
153
+
154
+ // Recalculate averages
155
+ _recalcAverages(metrics);
156
+ saveMetrics(metrics, metricsPath); // write INSIDE lock
157
+ return metrics.phase_metrics.find(m => m.phase === data.phase);
158
+ });
159
+ }
160
+
161
+ /**
162
+ * Record BDD verification results (called by ez-verifier)
163
+ * @param {object} data - { phase, bdd_must_passing, bdd_must_total, bdd_pass_rate, test_coverage_pct }
164
+ */
165
+ async function recordBddMetrics(data, metricsPath = METRICS_PATH) {
166
+ const fullPath = path.resolve(process.cwd(), metricsPath);
167
+ return withLock(fullPath, async () => {
168
+ const metrics = loadMetrics(metricsPath); // read INSIDE lock
169
+
170
+ const phaseEntry = metrics.phase_metrics.find(m => m.phase === data.phase);
171
+ if (phaseEntry) {
172
+ phaseEntry.bdd_pass_rate = data.bdd_pass_rate;
173
+ phaseEntry.bdd_must_passing = data.bdd_must_passing;
174
+ phaseEntry.bdd_must_total = data.bdd_must_total;
175
+ }
176
+
177
+ if (data.test_coverage_pct !== undefined) {
178
+ metrics.project_metrics.test_coverage_pct = data.test_coverage_pct;
179
+ }
180
+ if (data.bdd_must_passing !== undefined) {
181
+ metrics.project_metrics.bdd_scenarios_must_passing = data.bdd_must_passing;
182
+ metrics.project_metrics.bdd_scenarios_must = data.bdd_must_total;
183
+ }
184
+
185
+ saveMetrics(metrics, metricsPath); // write INSIDE lock
186
+ });
187
+ }
188
+
189
+ /**
190
+ * Record release event (called by ez-release-agent)
191
+ * @param {object} data - { tier, version, is_hotfix }
192
+ */
193
+ async function recordRelease(data, metricsPath = METRICS_PATH) {
194
+ const fullPath = path.resolve(process.cwd(), metricsPath);
195
+ return withLock(fullPath, async () => {
196
+ const metrics = loadMetrics(metricsPath); // read INSIDE lock
197
+
198
+ if (data.is_hotfix) {
199
+ metrics.business_metrics.hotfixes_deployed =
200
+ (metrics.business_metrics.hotfixes_deployed || 0) + 1;
201
+ } else {
202
+ metrics.business_metrics.milestones_shipped =
203
+ (metrics.business_metrics.milestones_shipped || 0) + 1;
204
+
205
+ if (!metrics.business_metrics.first_ship_date) {
206
+ metrics.business_metrics.first_ship_date = new Date().toISOString();
207
+ // Calculate time to first ship from project init
208
+ const planningStateFile = '.planning/STATE.md';
209
+ if (fs.existsSync(planningStateFile)) {
210
+ const stat = fs.statSync(planningStateFile);
211
+ const initDate = stat.birthtime || stat.mtime;
212
+ const days = Math.ceil((Date.now() - initDate.getTime()) / (1000 * 60 * 60 * 24));
213
+ metrics.business_metrics.time_to_first_ship_days = days;
214
+ }
215
+ }
216
+ }
217
+
218
+ if (data.tier) {
219
+ metrics.business_metrics.current_tier = data.tier;
220
+ }
221
+
222
+ saveMetrics(metrics, metricsPath); // write INSIDE lock
223
+ });
224
+ }
225
+
226
+ /**
227
+ * Update project-level metrics
228
+ * @param {object} data - { requirements_coverage_pct, phases_total, phases_completed }
229
+ */
230
+ async function updateProjectMetrics(data, metricsPath = METRICS_PATH) {
231
+ const fullPath = path.resolve(process.cwd(), metricsPath);
232
+ return withLock(fullPath, async () => {
233
+ const metrics = loadMetrics(metricsPath); // read INSIDE lock
234
+ Object.assign(metrics.project_metrics, data);
235
+ Object.assign(metrics.business_metrics, {
236
+ phases_total: data.phases_total || metrics.business_metrics.phases_total,
237
+ phases_completed: data.phases_completed || metrics.business_metrics.phases_completed
238
+ });
239
+ saveMetrics(metrics, metricsPath); // write INSIDE lock
240
+ });
241
+ }
242
+
243
+ // ─────────────────────────────────────────────
244
+ // Dashboard
245
+ // ─────────────────────────────────────────────
246
+
247
+ /**
248
+ * Generate enhanced /ez:stats dashboard
249
+ * @param {string} metricsPath
250
+ * @returns {string} Formatted dashboard
251
+ */
252
+ function generateDashboard(metricsPath = METRICS_PATH) {
253
+ const metrics = loadMetrics(metricsPath);
254
+ const pm = metrics.project_metrics;
255
+ const am = metrics.agent_metrics;
256
+ const bm = metrics.business_metrics;
257
+
258
+ // Velocity trend
259
+ const phases = metrics.phase_metrics.slice(-5);
260
+ let velocityTrend = '→ STABLE';
261
+ if (phases.length >= 3) {
262
+ const recent = phases.slice(-2).reduce((sum, p) => sum + (p.velocity_min || 0), 0) / 2;
263
+ const older = phases.slice(0, -2).reduce((sum, p) => sum + (p.velocity_min || 0), 0) / Math.max(1, phases.length - 2);
264
+ if (recent < older * 0.9) velocityTrend = '↑ IMPROVING';
265
+ else if (recent > older * 1.1) velocityTrend = '↓ SLOWING';
266
+ }
267
+
268
+ // BDD status
269
+ const bddRate = pm.bdd_scenarios_must > 0
270
+ ? Math.round((pm.bdd_scenarios_must_passing / pm.bdd_scenarios_must) * 100)
271
+ : null;
272
+
273
+ // Cost estimate remaining
274
+ const remainingPhases = (bm.phases_total || 0) - (bm.phases_completed || 0);
275
+ const costEst = am.avg_cost_per_plan > 0 && am.avg_plans_per_phase > 0
276
+ ? (remainingPhases * am.avg_plans_per_phase * am.avg_cost_per_plan).toFixed(2)
277
+ : null;
278
+
279
+ const lines = [
280
+ '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━',
281
+ ' EZ ► PROJECT METRICS DASHBOARD',
282
+ '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━',
283
+ '',
284
+ `PROGRESS: Phase ${bm.phases_completed || '?'}/${bm.phases_total || '?'} (${bm.phases_total ? Math.round((bm.phases_completed / bm.phases_total) * 100) : '?'}%) | Requirements ${pm.requirements_coverage_pct || 0}%${bddRate !== null ? ` | BDD @must ${bddRate}% (est.)` : ''}`,
285
+ `VELOCITY: ${am.avg_velocity_min_per_plan || '?'} min/plan avg | Trend: ${velocityTrend}`,
286
+ ];
287
+
288
+ // Fix 12: QUALITY line with threshold indicators
289
+ const coveragePct = pm.test_coverage_pct || 0;
290
+ const deviationRate = am.deviation_rate || 0;
291
+ const coverageIcon = getThresholdStatus(coveragePct, METRIC_THRESHOLDS.test_coverage);
292
+ const deviationIcon = getThresholdStatus(deviationRate, METRIC_THRESHOLDS.deviation_rate, true);
293
+ const deviationPct = Math.round(deviationRate * 100);
294
+ lines.push(`QUALITY: ${coverageIcon} Coverage ${coveragePct}% | ${deviationIcon} Deviation ${deviationPct}%${deviationRate >= METRIC_THRESHOLDS.deviation_rate.warn ? ` (warn: >${Math.round(METRIC_THRESHOLDS.deviation_rate.warn * 100)}%)` : ''} | Defect density ${am.deviation_rate ? am.deviation_rate.toFixed(2) : '?'}`);
295
+ lines.push(`COSTS: $${(am.total_token_cost_usd || 0).toFixed(2)} total | $${(am.avg_cost_per_plan || 0).toFixed(2)}/plan${costEst ? ` | Est. remaining: ~$${costEst}` : ''}`);
296
+ lines.push(`RELEASE: Tier: ${bm.current_tier || 'mvp'} | Hotfixes: ${bm.hotfixes_deployed || 0} | Ships: ${bm.milestones_shipped || 0}`);
297
+ lines.push('');
298
+
299
+ if (metrics.phase_metrics.length > 0) {
300
+ lines.push('Recent Phases:');
301
+ for (const p of metrics.phase_metrics.slice(-5).reverse()) {
302
+ const bdd = p.bdd_pass_rate !== null && p.bdd_pass_rate !== undefined
303
+ ? ` | BDD ${Math.round(p.bdd_pass_rate * 100)}%`
304
+ : '';
305
+ lines.push(` Phase ${p.phase} ${p.phase_name}: ${p.velocity_min || '?'}min | dev:${(p.defect_density || 0).toFixed(2)}${bdd}`);
306
+ }
307
+ lines.push('');
308
+ }
309
+
310
+ // Fix 12: Cost projection alert
311
+ if (am.total_token_cost_usd > 5) {
312
+ const projectedTotal = costEst ? parseFloat(costEst) + am.total_token_cost_usd : null;
313
+ if (projectedTotal && projectedTotal > 30) {
314
+ lines.push(`⚠ COST ALERT: Projected total ~$${projectedTotal.toFixed(0)}. Consider disabling scrum_master_standup for budget tiers.`);
315
+ }
316
+ }
317
+
318
+ lines.push('─────────────────────────────────────────────────────');
319
+ lines.push('⚠ BDD rates are ESTIMATED (scenario existence, not test runs).');
320
+ lines.push(' For actual pass rates, configure a BDD runner (Jest/Cucumber/Playwright).');
321
+ lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
322
+
323
+ return lines.join('\n');
324
+ }
325
+
326
+ // ─────────────────────────────────────────────
327
+ // Internal helpers
328
+ // ─────────────────────────────────────────────
329
+
330
+ function _recalcAverages(metrics) {
331
+ const phases = metrics.phase_metrics;
332
+ if (phases.length === 0) return;
333
+
334
+ const withVelocity = phases.filter(p => p.velocity_min > 0);
335
+ if (withVelocity.length > 0) {
336
+ metrics.agent_metrics.avg_velocity_min_per_plan =
337
+ Math.round(withVelocity.reduce((sum, p) => sum + p.velocity_min, 0) / withVelocity.length);
338
+ }
339
+
340
+ const totalDeviations = phases.reduce((sum, p) => sum + (p.deviation_count || 0), 0);
341
+ const totalPlans = phases.reduce((sum, p) => sum + (p.plans_completed || 0), 0);
342
+ if (totalPlans > 0) {
343
+ metrics.agent_metrics.deviation_rate = parseFloat((totalDeviations / totalPlans).toFixed(3));
344
+ metrics.agent_metrics.avg_plans_per_phase = parseFloat((totalPlans / phases.length).toFixed(1));
345
+ }
346
+
347
+ if (metrics.agent_metrics.total_token_cost_usd > 0 && totalPlans > 0) {
348
+ metrics.agent_metrics.avg_cost_per_plan =
349
+ parseFloat((metrics.agent_metrics.total_token_cost_usd / totalPlans).toFixed(3));
350
+ }
351
+ }
352
+
353
+ // ─────────────────────────────────────────────
354
+ // CLI
355
+ // ─────────────────────────────────────────────
356
+
357
+ if (require.main === module) {
358
+ const args = process.argv.slice(2);
359
+ const cmd = args[0];
360
+
361
+ (async () => {
362
+ try {
363
+ if (cmd === 'record-plan') {
364
+ const data = JSON.parse(args[1] || '{}');
365
+ const result = await recordPlanMetrics(data);
366
+ console.log(JSON.stringify({ recorded: true, phase: result }));
367
+ } else if (cmd === 'record-bdd') {
368
+ const data = JSON.parse(args[1] || '{}');
369
+ await recordBddMetrics(data);
370
+ console.log(JSON.stringify({ recorded: true }));
371
+ } else if (cmd === 'record-release') {
372
+ const data = JSON.parse(args[1] || '{}');
373
+ await recordRelease(data);
374
+ console.log(JSON.stringify({ recorded: true }));
375
+ } else if (cmd === 'update-project') {
376
+ const data = JSON.parse(args[1] || '{}');
377
+ await updateProjectMetrics(data);
378
+ console.log(JSON.stringify({ updated: true }));
379
+ } else if (cmd === 'dashboard') {
380
+ console.log(generateDashboard());
381
+ } else if (cmd === 'get') {
382
+ const metrics = loadMetrics();
383
+ console.log(JSON.stringify(metrics, null, 2));
384
+ } else {
385
+ console.error('Commands: record-plan, record-bdd, record-release, update-project, dashboard, get');
386
+ process.exit(1);
387
+ }
388
+ } catch (err) {
389
+ console.error(`Error: ${err.message}`);
390
+ process.exit(1);
391
+ }
392
+ })();
393
+ }
394
+
395
+ module.exports = {
396
+ loadMetrics,
397
+ saveMetrics,
398
+ saveMetricsLocked,
399
+ recordPlanMetrics,
400
+ recordBddMetrics,
401
+ recordRelease,
402
+ updateProjectMetrics,
403
+ generateDashboard,
404
+ METRIC_THRESHOLDS,
405
+ getThresholdStatus
406
+ };