@weldr/runr 0.3.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 (73) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/LICENSE +190 -0
  3. package/NOTICE +4 -0
  4. package/README.md +200 -0
  5. package/dist/cli.js +464 -0
  6. package/dist/commands/__tests__/report.test.js +202 -0
  7. package/dist/commands/compare.js +168 -0
  8. package/dist/commands/doctor.js +124 -0
  9. package/dist/commands/follow.js +251 -0
  10. package/dist/commands/gc.js +161 -0
  11. package/dist/commands/guards-only.js +89 -0
  12. package/dist/commands/metrics.js +441 -0
  13. package/dist/commands/orchestrate.js +800 -0
  14. package/dist/commands/paths.js +31 -0
  15. package/dist/commands/preflight.js +152 -0
  16. package/dist/commands/report.js +478 -0
  17. package/dist/commands/resume.js +149 -0
  18. package/dist/commands/run.js +538 -0
  19. package/dist/commands/status.js +189 -0
  20. package/dist/commands/summarize.js +220 -0
  21. package/dist/commands/version.js +82 -0
  22. package/dist/commands/wait.js +170 -0
  23. package/dist/config/__tests__/presets.test.js +104 -0
  24. package/dist/config/load.js +66 -0
  25. package/dist/config/schema.js +160 -0
  26. package/dist/context/__tests__/artifact.test.js +130 -0
  27. package/dist/context/__tests__/pack.test.js +191 -0
  28. package/dist/context/artifact.js +67 -0
  29. package/dist/context/index.js +2 -0
  30. package/dist/context/pack.js +273 -0
  31. package/dist/diagnosis/analyzer.js +678 -0
  32. package/dist/diagnosis/formatter.js +136 -0
  33. package/dist/diagnosis/index.js +6 -0
  34. package/dist/diagnosis/types.js +7 -0
  35. package/dist/env/__tests__/fingerprint.test.js +116 -0
  36. package/dist/env/fingerprint.js +111 -0
  37. package/dist/orchestrator/__tests__/policy.test.js +185 -0
  38. package/dist/orchestrator/__tests__/schema-version.test.js +65 -0
  39. package/dist/orchestrator/artifacts.js +405 -0
  40. package/dist/orchestrator/state-machine.js +646 -0
  41. package/dist/orchestrator/types.js +88 -0
  42. package/dist/ownership/normalize.js +45 -0
  43. package/dist/repo/context.js +90 -0
  44. package/dist/repo/git.js +13 -0
  45. package/dist/repo/worktree.js +239 -0
  46. package/dist/store/run-store.js +107 -0
  47. package/dist/store/run-utils.js +69 -0
  48. package/dist/store/runs-root.js +126 -0
  49. package/dist/supervisor/__tests__/evidence-gate.test.js +111 -0
  50. package/dist/supervisor/__tests__/ownership.test.js +103 -0
  51. package/dist/supervisor/__tests__/state-machine.test.js +290 -0
  52. package/dist/supervisor/collision.js +240 -0
  53. package/dist/supervisor/evidence-gate.js +98 -0
  54. package/dist/supervisor/planner.js +18 -0
  55. package/dist/supervisor/runner.js +1562 -0
  56. package/dist/supervisor/scope-guard.js +55 -0
  57. package/dist/supervisor/state-machine.js +121 -0
  58. package/dist/supervisor/verification-policy.js +64 -0
  59. package/dist/tasks/task-metadata.js +72 -0
  60. package/dist/types/schemas.js +1 -0
  61. package/dist/verification/engine.js +49 -0
  62. package/dist/workers/__tests__/claude.test.js +88 -0
  63. package/dist/workers/__tests__/codex.test.js +81 -0
  64. package/dist/workers/claude.js +119 -0
  65. package/dist/workers/codex.js +162 -0
  66. package/dist/workers/json.js +22 -0
  67. package/dist/workers/mock.js +193 -0
  68. package/dist/workers/prompts.js +98 -0
  69. package/dist/workers/schemas.js +39 -0
  70. package/package.json +47 -0
  71. package/templates/prompts/implementer.md +70 -0
  72. package/templates/prompts/planner.md +62 -0
  73. package/templates/prompts/reviewer.md +77 -0
@@ -0,0 +1,161 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getRunrPaths } from '../store/runs-root.js';
4
+ /**
5
+ * Get directory size recursively (in bytes)
6
+ */
7
+ function getDirSize(dirPath) {
8
+ let size = 0;
9
+ try {
10
+ const items = fs.readdirSync(dirPath, { withFileTypes: true });
11
+ for (const item of items) {
12
+ const itemPath = path.join(dirPath, item.name);
13
+ if (item.isDirectory()) {
14
+ size += getDirSize(itemPath);
15
+ }
16
+ else if (item.isFile()) {
17
+ const stat = fs.statSync(itemPath);
18
+ size += stat.size;
19
+ }
20
+ }
21
+ }
22
+ catch {
23
+ // Ignore errors (permission denied, etc.)
24
+ }
25
+ return size;
26
+ }
27
+ /**
28
+ * Format bytes to human-readable size
29
+ */
30
+ function formatSize(bytes) {
31
+ if (bytes < 1024)
32
+ return `${bytes}B`;
33
+ if (bytes < 1024 * 1024)
34
+ return `${(bytes / 1024).toFixed(1)}KB`;
35
+ if (bytes < 1024 * 1024 * 1024)
36
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
37
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)}GB`;
38
+ }
39
+ /**
40
+ * Get modification time of directory (most recent file)
41
+ */
42
+ function getDirModTime(dirPath) {
43
+ try {
44
+ const stat = fs.statSync(dirPath);
45
+ return stat.mtime;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ /**
52
+ * Recursively delete a directory
53
+ */
54
+ function rmDir(dirPath) {
55
+ fs.rmSync(dirPath, { recursive: true, force: true });
56
+ }
57
+ /**
58
+ * Scan worktrees (current and legacy) and gather usage info
59
+ */
60
+ function listRunIds(dirPath) {
61
+ if (!fs.existsSync(dirPath)) {
62
+ return [];
63
+ }
64
+ return fs.readdirSync(dirPath, { withFileTypes: true })
65
+ .filter(entry => entry.isDirectory() && /^\d{14}$/.test(entry.name))
66
+ .map(entry => entry.name);
67
+ }
68
+ /**
69
+ * Scan worktrees in all locations:
70
+ * - Current: .runr-worktrees/<runId>/ (or .agent-worktrees/ for legacy)
71
+ * - Legacy v1: .agent/runs/<runId>/worktree/
72
+ * - Legacy v2: .agent/worktrees/<runId>/ (pre-migration)
73
+ */
74
+ function scanWorktrees(runsDir, worktreesDir, runrRoot) {
75
+ const worktrees = [];
76
+ const now = new Date();
77
+ // Legacy v2 location: .agent/worktrees/ (worktrees inside .agent before the fix)
78
+ const legacyWorktreesDir = path.join(runrRoot, 'worktrees');
79
+ const runIds = new Set([
80
+ ...listRunIds(runsDir),
81
+ ...listRunIds(worktreesDir),
82
+ ...listRunIds(legacyWorktreesDir)
83
+ ]);
84
+ for (const runId of runIds) {
85
+ const newPath = path.join(worktreesDir, runId);
86
+ const legacyV1Path = path.join(runsDir, runId, 'worktree');
87
+ const legacyV2Path = path.join(legacyWorktreesDir, runId);
88
+ const candidates = [];
89
+ if (fs.existsSync(newPath)) {
90
+ candidates.push({ path: newPath, label: runId });
91
+ }
92
+ if (fs.existsSync(legacyV2Path) && legacyV2Path !== newPath) {
93
+ candidates.push({ path: legacyV2Path, label: `${runId} (legacy v2)` });
94
+ }
95
+ if (fs.existsSync(legacyV1Path)) {
96
+ candidates.push({ path: legacyV1Path, label: `${runId} (legacy v1)` });
97
+ }
98
+ for (const candidate of candidates) {
99
+ const worktreeSize = getDirSize(candidate.path);
100
+ const worktreeModified = getDirModTime(candidate.path);
101
+ const ageDays = worktreeModified
102
+ ? Math.floor((now.getTime() - worktreeModified.getTime()) / (1000 * 60 * 60 * 24))
103
+ : null;
104
+ worktrees.push({
105
+ runId,
106
+ label: candidate.label,
107
+ worktreePath: candidate.path,
108
+ worktreeSize,
109
+ worktreeModified,
110
+ ageDays
111
+ });
112
+ }
113
+ }
114
+ return worktrees.sort((a, b) => a.runId.localeCompare(b.runId));
115
+ }
116
+ export async function gcCommand(options) {
117
+ const paths = getRunrPaths(options.repo);
118
+ const worktrees = scanWorktrees(paths.runs_dir, paths.worktrees_dir, paths.runr_root);
119
+ // Calculate totals
120
+ const totalRuns = worktrees.length;
121
+ const totalWorktreeSize = worktrees.reduce((sum, r) => sum + r.worktreeSize, 0);
122
+ // Find runs eligible for cleanup
123
+ const eligibleForCleanup = worktrees.filter(r => r.ageDays !== null && r.ageDays >= options.olderThan);
124
+ const cleanupSize = eligibleForCleanup.reduce((sum, r) => sum + r.worktreeSize, 0);
125
+ console.log('=== Disk Usage Summary ===\n');
126
+ console.log(`Total worktrees: ${totalRuns}`);
127
+ console.log(`Total worktree size: ${formatSize(totalWorktreeSize)}`);
128
+ console.log('');
129
+ if (worktrees.length > 0) {
130
+ console.log('=== Worktree Details ===\n');
131
+ console.log('| Worktree | Age (days) | Size |');
132
+ console.log('|-----------------|------------|----------|');
133
+ for (const run of worktrees) {
134
+ const age = run.ageDays !== null ? String(run.ageDays).padStart(10) : ' N/A';
135
+ const size = formatSize(run.worktreeSize).padStart(8);
136
+ console.log(`| ${run.label} | ${age} | ${size} |`);
137
+ }
138
+ console.log('');
139
+ }
140
+ if (eligibleForCleanup.length === 0) {
141
+ console.log(`No worktrees older than ${options.olderThan} days found.`);
142
+ return;
143
+ }
144
+ console.log(`=== Cleanup ${options.dryRun ? '(DRY RUN)' : ''} ===\n`);
145
+ console.log(`Found ${eligibleForCleanup.length} worktrees older than ${options.olderThan} days`);
146
+ console.log(`Total size to reclaim: ${formatSize(cleanupSize)}\n`);
147
+ for (const run of eligibleForCleanup) {
148
+ const msg = `${options.dryRun ? '[DRY RUN] Would delete' : 'Deleting'}: ${run.label} (${formatSize(run.worktreeSize)}, ${run.ageDays}d old)`;
149
+ console.log(msg);
150
+ if (!options.dryRun) {
151
+ rmDir(run.worktreePath);
152
+ }
153
+ }
154
+ console.log('');
155
+ if (options.dryRun) {
156
+ console.log(`Dry run complete. Use without --dry-run to actually delete.`);
157
+ }
158
+ else {
159
+ console.log(`Cleanup complete. Reclaimed ${formatSize(cleanupSize)}.`);
160
+ }
161
+ }
@@ -0,0 +1,89 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { loadConfig, resolveConfigPath } from '../config/load.js';
4
+ import { RunStore } from '../store/run-store.js';
5
+ import { getRunsRoot } from '../store/runs-root.js';
6
+ import { buildMilestonesFromTask } from '../supervisor/planner.js';
7
+ import { runPreflight } from './preflight.js';
8
+ function makeRunId() {
9
+ const now = new Date();
10
+ const parts = [
11
+ now.getUTCFullYear(),
12
+ String(now.getUTCMonth() + 1).padStart(2, '0'),
13
+ String(now.getUTCDate()).padStart(2, '0'),
14
+ String(now.getUTCHours()).padStart(2, '0'),
15
+ String(now.getUTCMinutes()).padStart(2, '0'),
16
+ String(now.getUTCSeconds()).padStart(2, '0')
17
+ ];
18
+ return parts.join('');
19
+ }
20
+ function slugFromTask(taskPath) {
21
+ const base = path.basename(taskPath, path.extname(taskPath));
22
+ return base.toLowerCase().replace(/[^a-z0-9-]/g, '-');
23
+ }
24
+ function formatSummaryLine(input) {
25
+ return [
26
+ `run_id=${input.runId}`,
27
+ `run_dir=${input.runDir}`,
28
+ `repo_root=${input.repoRoot}`,
29
+ `current_branch=${input.currentBranch}`,
30
+ `planned_run_branch=${input.plannedRunBranch}`,
31
+ `guard=${input.guardOk ? 'pass' : 'fail'}`,
32
+ `tiers=${input.tiers.join('|')}`,
33
+ `tier_reasons=${input.tierReasons.join('|') || 'none'}`,
34
+ `no_write=${input.noWrite ? 'true' : 'false'}`
35
+ ].join(' ');
36
+ }
37
+ export async function guardsOnlyCommand(options) {
38
+ const repoPath = path.resolve(options.repo);
39
+ const taskPath = path.resolve(options.task);
40
+ const configPath = resolveConfigPath(repoPath, options.config);
41
+ const config = loadConfig(configPath);
42
+ const taskText = fs.readFileSync(taskPath, 'utf-8');
43
+ const milestones = buildMilestonesFromTask(taskText);
44
+ const milestoneRiskLevel = milestones[0]?.risk_level ?? 'medium';
45
+ const runId = makeRunId();
46
+ const slug = slugFromTask(taskPath);
47
+ const runDir = path.join(getRunsRoot(repoPath), runId);
48
+ const preflight = await runPreflight({
49
+ repoPath,
50
+ runId,
51
+ slug,
52
+ config,
53
+ allowDeps: options.allowDeps,
54
+ allowDirty: options.allowDirty,
55
+ milestoneRiskLevel,
56
+ skipPing: options.skipPing ?? true // Skip ping by default for guards-only
57
+ });
58
+ if (!options.noWrite) {
59
+ const runStore = RunStore.init(runId, repoPath);
60
+ runStore.writeConfigSnapshot(config);
61
+ runStore.writeArtifact('task.md', taskText);
62
+ runStore.appendEvent({
63
+ type: 'preflight',
64
+ source: 'cli',
65
+ payload: {
66
+ repo: preflight.repo_context,
67
+ guard: preflight.guard,
68
+ binary: preflight.binary,
69
+ ping: preflight.ping,
70
+ tiers: preflight.tiers,
71
+ tier_reasons: preflight.tier_reasons,
72
+ allow_dirty: options.allowDirty,
73
+ allow_deps: options.allowDeps
74
+ }
75
+ });
76
+ }
77
+ const summaryLine = formatSummaryLine({
78
+ runId,
79
+ runDir,
80
+ repoRoot: preflight.repo_context.git_root,
81
+ currentBranch: preflight.repo_context.current_branch,
82
+ plannedRunBranch: preflight.repo_context.run_branch,
83
+ guardOk: preflight.guard.ok,
84
+ tiers: preflight.tiers,
85
+ tierReasons: preflight.tier_reasons,
86
+ noWrite: options.noWrite
87
+ });
88
+ console.log(summaryLine);
89
+ }
@@ -0,0 +1,441 @@
1
+ /**
2
+ * Metrics aggregation command.
3
+ *
4
+ * Collects and aggregates metrics across all runs and orchestrations.
5
+ * Designed for fast collection with O(n) file reads.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { getRunsRoot, getOrchestrationsRoot, getAgentPaths } from '../store/runs-root.js';
11
+ // Get agent version from package.json
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+ const packageJsonPath = path.resolve(__dirname, '../../package.json');
14
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
15
+ const AGENT_VERSION = packageJson.version;
16
+ /**
17
+ * Parse a run ID to get its timestamp.
18
+ * Run IDs are in format YYYYMMDDHHmmss.
19
+ */
20
+ function parseRunTimestamp(runId) {
21
+ const match = runId.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/);
22
+ if (!match)
23
+ return null;
24
+ const [, year, month, day, hour, minute, second] = match;
25
+ return new Date(parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10), parseInt(hour, 10), parseInt(minute, 10), parseInt(second, 10));
26
+ }
27
+ /**
28
+ * Parse an orchestrator ID to get its timestamp.
29
+ * Orchestrator IDs are in format orch-YYYYMMDD-HHmmss-XXX.
30
+ */
31
+ function parseOrchTimestamp(orchId) {
32
+ const match = orchId.match(/^orch-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})/);
33
+ if (!match)
34
+ return null;
35
+ const [, year, month, day, hour, minute, second] = match;
36
+ return new Date(parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10), parseInt(hour, 10), parseInt(minute, 10), parseInt(second, 10));
37
+ }
38
+ /**
39
+ * Compute percentiles from a sorted array of numbers.
40
+ */
41
+ function computePercentiles(sortedDurations) {
42
+ if (sortedDurations.length === 0) {
43
+ return { p50: null, p90: null, max: null };
44
+ }
45
+ const p50Index = Math.floor(sortedDurations.length * 0.5);
46
+ const p90Index = Math.floor(sortedDurations.length * 0.9);
47
+ return {
48
+ p50: Math.round(sortedDurations[p50Index]),
49
+ p90: Math.round(sortedDurations[p90Index]),
50
+ max: Math.round(sortedDurations[sortedDurations.length - 1])
51
+ };
52
+ }
53
+ /**
54
+ * Get top N stop reasons sorted by count descending.
55
+ */
56
+ function getTopStopReasons(byStopReason, limit = 5) {
57
+ return Object.entries(byStopReason)
58
+ .map(([reason, count]) => ({ reason, count }))
59
+ .sort((a, b) => b.count - a.count)
60
+ .slice(0, limit);
61
+ }
62
+ /**
63
+ * Collect metrics from all runs and orchestrations.
64
+ */
65
+ export function collectMetrics(repoPath, days = 30, windowLimit) {
66
+ const now = new Date();
67
+ const cutoffDate = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
68
+ const runsRoot = getRunsRoot(repoPath);
69
+ const orchRoot = getOrchestrationsRoot(repoPath);
70
+ const agentPaths = getAgentPaths(repoPath);
71
+ // Track filtering
72
+ let runsFilteredOut = 0;
73
+ let orchsFilteredOut = 0;
74
+ // Track durations for percentile calculation
75
+ const runDurations = [];
76
+ const orchDurations = [];
77
+ // Track orchestration stop reasons
78
+ const orchByStopReason = {};
79
+ // Track collisions
80
+ let totalCollisions = 0;
81
+ const collisionsByStage = {};
82
+ // Initialize counters
83
+ const metrics = {
84
+ schema_version: 1,
85
+ agent_version: AGENT_VERSION,
86
+ repo_root: path.resolve(repoPath),
87
+ collected_at: now.toISOString(),
88
+ period: {
89
+ days,
90
+ from: cutoffDate.toISOString(),
91
+ to: now.toISOString(),
92
+ runs_considered: 0,
93
+ runs_filtered_out: 0,
94
+ window: windowLimit ?? null
95
+ },
96
+ paths: agentPaths,
97
+ runs: {
98
+ total: 0,
99
+ complete: 0,
100
+ stopped: 0,
101
+ running: 0,
102
+ success_rate: 0,
103
+ by_stop_reason: {},
104
+ top_stop_reasons: [],
105
+ avg_duration_ms: null,
106
+ durations_ms: { p50: null, p90: null, max: null }
107
+ },
108
+ orchestrations: {
109
+ total: 0,
110
+ complete: 0,
111
+ stopped: 0,
112
+ failed: 0,
113
+ running: 0,
114
+ success_rate: 0,
115
+ by_collision_policy: {},
116
+ top_stop_reasons: [],
117
+ durations_ms: { p50: null, p90: null, max: null }
118
+ },
119
+ collisions: {
120
+ total: 0,
121
+ by_stage: {}
122
+ },
123
+ workers: {
124
+ total_calls: 0,
125
+ claude: 0,
126
+ codex: 0
127
+ },
128
+ auto_resume: {
129
+ total_attempts: 0,
130
+ successful_recoveries: 0
131
+ },
132
+ milestones: {
133
+ total_completed: 0,
134
+ avg_per_run: 0
135
+ }
136
+ };
137
+ let totalDurationMs = 0;
138
+ let durationCount = 0;
139
+ let oldestRun = null;
140
+ // Collect run metrics (fast path: only read state.json, not timeline)
141
+ if (fs.existsSync(runsRoot)) {
142
+ // Get all run directories with their timestamps
143
+ const runDirs = fs.readdirSync(runsRoot, { withFileTypes: true })
144
+ .filter(d => d.isDirectory())
145
+ .map(d => ({ name: d.name, timestamp: parseRunTimestamp(d.name) }))
146
+ .filter(r => r.timestamp !== null);
147
+ // Sort by timestamp descending (newest first) for window limiting
148
+ runDirs.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
149
+ // Apply window limit (default 50 for runs)
150
+ const runWindowLimit = windowLimit ?? 50;
151
+ let runCount = 0;
152
+ for (const { name: runId, timestamp } of runDirs) {
153
+ // Filter by date cutoff
154
+ if (timestamp < cutoffDate) {
155
+ runsFilteredOut++;
156
+ continue;
157
+ }
158
+ // Apply window limit
159
+ if (runCount >= runWindowLimit) {
160
+ runsFilteredOut++;
161
+ continue;
162
+ }
163
+ runCount++;
164
+ if (!oldestRun || timestamp < oldestRun) {
165
+ oldestRun = timestamp;
166
+ }
167
+ const statePath = path.join(runsRoot, runId, 'state.json');
168
+ if (!fs.existsSync(statePath))
169
+ continue;
170
+ try {
171
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
172
+ metrics.runs.total++;
173
+ // Count by phase/outcome
174
+ if (state.phase === 'STOPPED') {
175
+ metrics.runs.stopped++;
176
+ const reason = state.stop_reason ?? 'unknown';
177
+ metrics.runs.by_stop_reason[reason] = (metrics.runs.by_stop_reason[reason] ?? 0) + 1;
178
+ }
179
+ else if (state.phase === 'FINALIZE') {
180
+ metrics.runs.complete++;
181
+ }
182
+ else {
183
+ metrics.runs.running++;
184
+ }
185
+ // Duration (use updated_at for ended runs)
186
+ if (state.started_at && state.updated_at &&
187
+ (state.phase === 'STOPPED' || state.phase === 'FINALIZE')) {
188
+ const duration = new Date(state.updated_at).getTime() - new Date(state.started_at).getTime();
189
+ if (duration > 0) {
190
+ totalDurationMs += duration;
191
+ durationCount++;
192
+ runDurations.push(duration);
193
+ }
194
+ }
195
+ // Auto-resume count
196
+ if (state.auto_resume_count && state.auto_resume_count > 0) {
197
+ metrics.auto_resume.total_attempts += state.auto_resume_count;
198
+ // If completed after auto-resume, count as successful recovery
199
+ if (state.phase === 'FINALIZE') {
200
+ metrics.auto_resume.successful_recoveries++;
201
+ }
202
+ }
203
+ // Milestones completed (milestone_index indicates progress)
204
+ // If run is complete, all milestones are done; otherwise use milestone_index
205
+ const milestonesComplete = state.phase === 'FINALIZE'
206
+ ? state.milestones.length
207
+ : state.milestone_index;
208
+ metrics.milestones.total_completed += milestonesComplete;
209
+ }
210
+ catch {
211
+ // Skip runs with invalid state
212
+ }
213
+ }
214
+ }
215
+ // Set period filtering counts
216
+ metrics.period.runs_considered = metrics.runs.total;
217
+ metrics.period.runs_filtered_out = runsFilteredOut;
218
+ // Compute derived run metrics
219
+ if (metrics.runs.total > 0) {
220
+ // Use float with 1 decimal place for success_rate
221
+ metrics.runs.success_rate = Math.round((metrics.runs.complete / metrics.runs.total) * 1000) / 10;
222
+ metrics.milestones.avg_per_run = Math.round((metrics.milestones.total_completed / metrics.runs.total) * 10) / 10;
223
+ // Top stop reasons
224
+ metrics.runs.top_stop_reasons = getTopStopReasons(metrics.runs.by_stop_reason);
225
+ }
226
+ if (durationCount > 0) {
227
+ metrics.runs.avg_duration_ms = Math.round(totalDurationMs / durationCount);
228
+ // Duration percentiles
229
+ runDurations.sort((a, b) => a - b);
230
+ metrics.runs.durations_ms = computePercentiles(runDurations);
231
+ }
232
+ if (oldestRun) {
233
+ metrics.period.from = oldestRun.toISOString();
234
+ }
235
+ // Collect orchestration metrics
236
+ if (fs.existsSync(orchRoot)) {
237
+ // Get all orchestration directories with their timestamps
238
+ const orchDirs = fs.readdirSync(orchRoot, { withFileTypes: true })
239
+ .filter(d => d.isDirectory())
240
+ .map(d => ({ name: d.name, timestamp: parseOrchTimestamp(d.name) }))
241
+ .filter(o => o.timestamp !== null);
242
+ // Sort by timestamp descending (newest first) for window limiting
243
+ orchDirs.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
244
+ // Apply window limit (default 20 for orchestrations)
245
+ const orchWindowLimit = windowLimit ? Math.ceil(windowLimit * 0.4) : 20;
246
+ let orchCount = 0;
247
+ for (const { name: orchId, timestamp } of orchDirs) {
248
+ // Filter by date cutoff
249
+ if (timestamp < cutoffDate) {
250
+ orchsFilteredOut++;
251
+ continue;
252
+ }
253
+ // Apply window limit
254
+ if (orchCount >= orchWindowLimit) {
255
+ orchsFilteredOut++;
256
+ continue;
257
+ }
258
+ orchCount++;
259
+ const statePath = path.join(orchRoot, orchId, 'state.json');
260
+ if (!fs.existsSync(statePath))
261
+ continue;
262
+ try {
263
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
264
+ metrics.orchestrations.total++;
265
+ // Count by status
266
+ switch (state.status) {
267
+ case 'complete':
268
+ metrics.orchestrations.complete++;
269
+ break;
270
+ case 'stopped':
271
+ metrics.orchestrations.stopped++;
272
+ orchByStopReason['stopped'] = (orchByStopReason['stopped'] ?? 0) + 1;
273
+ break;
274
+ case 'failed':
275
+ metrics.orchestrations.failed++;
276
+ orchByStopReason['failed'] = (orchByStopReason['failed'] ?? 0) + 1;
277
+ break;
278
+ case 'running':
279
+ metrics.orchestrations.running++;
280
+ break;
281
+ }
282
+ // Count by collision policy
283
+ const policy = state.policy?.collision_policy ?? state.collision_policy ?? 'serialize';
284
+ metrics.orchestrations.by_collision_policy[policy] =
285
+ (metrics.orchestrations.by_collision_policy[policy] ?? 0) + 1;
286
+ // Duration (calculate from started_at to ended_at)
287
+ if (state.started_at && state.ended_at &&
288
+ (state.status === 'complete' || state.status === 'stopped' || state.status === 'failed')) {
289
+ const duration = new Date(state.ended_at).getTime() - new Date(state.started_at).getTime();
290
+ if (duration > 0) {
291
+ orchDurations.push(duration);
292
+ }
293
+ }
294
+ // Read summary.json for collision data if it exists
295
+ const summaryPath = path.join(orchRoot, orchId, 'summary.json');
296
+ if (fs.existsSync(summaryPath)) {
297
+ try {
298
+ const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf-8'));
299
+ if (summary.collisions && Array.isArray(summary.collisions)) {
300
+ for (const collision of summary.collisions) {
301
+ totalCollisions++;
302
+ const stage = collision.stage ?? 'unknown';
303
+ collisionsByStage[stage] = (collisionsByStage[stage] ?? 0) + 1;
304
+ }
305
+ }
306
+ }
307
+ catch {
308
+ // Skip invalid summary
309
+ }
310
+ }
311
+ }
312
+ catch {
313
+ // Skip orchestrations with invalid state
314
+ }
315
+ }
316
+ }
317
+ // Compute derived orchestration metrics
318
+ if (metrics.orchestrations.total > 0) {
319
+ // Use float with 1 decimal place for success_rate
320
+ metrics.orchestrations.success_rate = Math.round((metrics.orchestrations.complete / metrics.orchestrations.total) * 1000) / 10;
321
+ // Top stop reasons
322
+ metrics.orchestrations.top_stop_reasons = getTopStopReasons(orchByStopReason);
323
+ }
324
+ // Duration percentiles for orchestrations
325
+ if (orchDurations.length > 0) {
326
+ orchDurations.sort((a, b) => a - b);
327
+ metrics.orchestrations.durations_ms = computePercentiles(orchDurations);
328
+ }
329
+ // Collision stats
330
+ metrics.collisions.total = totalCollisions;
331
+ metrics.collisions.by_stage = collisionsByStage;
332
+ return metrics;
333
+ }
334
+ /**
335
+ * Format duration in human-readable form.
336
+ */
337
+ function formatDuration(ms) {
338
+ if (ms < 1000)
339
+ return `${ms}ms`;
340
+ if (ms < 60000)
341
+ return `${Math.round(ms / 1000)}s`;
342
+ return `${Math.round(ms / 60000)}m`;
343
+ }
344
+ /**
345
+ * Format metrics for human-readable output.
346
+ */
347
+ function formatMetrics(metrics) {
348
+ const lines = [];
349
+ lines.push('='.repeat(60));
350
+ lines.push('METRICS SUMMARY');
351
+ lines.push('='.repeat(60));
352
+ lines.push('');
353
+ lines.push(`Agent: v${metrics.agent_version} (schema v${metrics.schema_version})`);
354
+ lines.push(`Repo: ${metrics.repo_root}`);
355
+ lines.push('');
356
+ lines.push(`Period: ${metrics.period.days} days`);
357
+ if (metrics.period.window) {
358
+ lines.push(`Window: ${metrics.period.window} runs`);
359
+ }
360
+ lines.push(`From: ${metrics.period.from ?? 'N/A'}`);
361
+ lines.push(`To: ${metrics.period.to}`);
362
+ lines.push(`Runs considered: ${metrics.period.runs_considered} (${metrics.period.runs_filtered_out} filtered out)`);
363
+ lines.push('');
364
+ lines.push('RUNS');
365
+ lines.push('-'.repeat(30));
366
+ lines.push(`Total: ${metrics.runs.total}`);
367
+ lines.push(`Complete: ${metrics.runs.complete}`);
368
+ lines.push(`Stopped: ${metrics.runs.stopped}`);
369
+ lines.push(`Running: ${metrics.runs.running}`);
370
+ lines.push(`Success rate: ${metrics.runs.success_rate}%`);
371
+ if (metrics.runs.avg_duration_ms !== null) {
372
+ lines.push(`Avg duration: ${formatDuration(metrics.runs.avg_duration_ms)}`);
373
+ }
374
+ if (metrics.runs.durations_ms.p50 !== null) {
375
+ lines.push(`Duration p50/p90/max: ${formatDuration(metrics.runs.durations_ms.p50)}/${formatDuration(metrics.runs.durations_ms.p90)}/${formatDuration(metrics.runs.durations_ms.max)}`);
376
+ }
377
+ lines.push('');
378
+ if (metrics.runs.top_stop_reasons.length > 0) {
379
+ lines.push('Top stop reasons:');
380
+ for (const { reason, count } of metrics.runs.top_stop_reasons) {
381
+ lines.push(` ${reason}: ${count}`);
382
+ }
383
+ lines.push('');
384
+ }
385
+ lines.push('ORCHESTRATIONS');
386
+ lines.push('-'.repeat(30));
387
+ lines.push(`Total: ${metrics.orchestrations.total}`);
388
+ lines.push(`Complete: ${metrics.orchestrations.complete}`);
389
+ lines.push(`Stopped: ${metrics.orchestrations.stopped}`);
390
+ lines.push(`Failed: ${metrics.orchestrations.failed}`);
391
+ lines.push(`Running: ${metrics.orchestrations.running}`);
392
+ lines.push(`Success rate: ${metrics.orchestrations.success_rate}%`);
393
+ if (metrics.orchestrations.durations_ms.p50 !== null) {
394
+ lines.push(`Duration p50/p90/max: ${formatDuration(metrics.orchestrations.durations_ms.p50)}/${formatDuration(metrics.orchestrations.durations_ms.p90)}/${formatDuration(metrics.orchestrations.durations_ms.max)}`);
395
+ }
396
+ lines.push('');
397
+ if (Object.keys(metrics.orchestrations.by_collision_policy).length > 0) {
398
+ lines.push('By collision policy:');
399
+ for (const [policy, count] of Object.entries(metrics.orchestrations.by_collision_policy)) {
400
+ lines.push(` ${policy}: ${count}`);
401
+ }
402
+ lines.push('');
403
+ }
404
+ if (metrics.collisions.total > 0) {
405
+ lines.push('COLLISIONS');
406
+ lines.push('-'.repeat(30));
407
+ lines.push(`Total: ${metrics.collisions.total}`);
408
+ for (const [stage, count] of Object.entries(metrics.collisions.by_stage)) {
409
+ lines.push(` ${stage}: ${count}`);
410
+ }
411
+ lines.push('');
412
+ }
413
+ lines.push('MILESTONES');
414
+ lines.push('-'.repeat(30));
415
+ lines.push(`Total completed: ${metrics.milestones.total_completed}`);
416
+ lines.push(`Avg per run: ${metrics.milestones.avg_per_run}`);
417
+ lines.push('');
418
+ if (metrics.auto_resume.total_attempts > 0) {
419
+ lines.push('AUTO-RESUME');
420
+ lines.push('-'.repeat(30));
421
+ lines.push(`Total attempts: ${metrics.auto_resume.total_attempts}`);
422
+ lines.push(`Successful recoveries: ${metrics.auto_resume.successful_recoveries}`);
423
+ lines.push('');
424
+ }
425
+ return lines.join('\n');
426
+ }
427
+ /**
428
+ * Run the metrics command.
429
+ */
430
+ export async function metricsCommand(options) {
431
+ const repoPath = path.resolve(options.repo);
432
+ const days = options.days ?? 30;
433
+ const windowLimit = options.window;
434
+ const metrics = collectMetrics(repoPath, days, windowLimit);
435
+ if (options.json) {
436
+ console.log(JSON.stringify(metrics, null, 2));
437
+ }
438
+ else {
439
+ console.log(formatMetrics(metrics));
440
+ }
441
+ }