diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index abf83773a..fd3b5d120 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "genie", - "version": "4.260522.20", + "version": "4.260606.2", "source": "./plugins/genie", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, wish them into plans, make with parallel agents, ship as one team. A coding genie that grows with your project." } diff --git a/.genie/agents/metrics-updater/daily-stats.jsonl b/.genie/agents/metrics-updater/daily-stats.jsonl index b4721d436..7b9b511d9 100644 --- a/.genie/agents/metrics-updater/daily-stats.jsonl +++ b/.genie/agents/metrics-updater/daily-stats.jsonl @@ -1,57 +1,32 @@ -{"date":"2026-03-13","commits":13,"loc_added":6920,"loc_removed":2948,"releases":0,"contributors":["Genie","Test","Test User"]} -{"date":"2026-03-14","commits":45,"loc_added":2761,"loc_removed":5243,"releases":0,"contributors":["Felipe Rosa","Test User","github-actions[bot]"]} -{"date":"2026-03-15","commits":14,"loc_added":3552,"loc_removed":3096,"releases":0,"contributors":["Automagik Genie 🧞","Felipe Rosa","Test User","github-actions[bot]"]} -{"date":"2026-03-16","commits":82,"loc_added":11464,"loc_removed":10990,"releases":0,"contributors":["Felipe Rosa","Test User","clawtom","github-actions[bot]"]} -{"date":"2026-03-17","commits":86,"loc_added":6482,"loc_removed":1928,"releases":0,"contributors":["Felipe Rosa","Genie","Test","Test User","github-actions[bot]"]} -{"date":"2026-03-18","commits":41,"loc_added":789,"loc_removed":4077,"releases":0,"contributors":["Felipe Rosa","Genie","github-actions[bot]"]} -{"date":"2026-03-19","commits":5,"loc_added":1183,"loc_removed":202,"releases":0,"contributors":["Felipe Rosa","Genie","Luís","github-actions[bot]"]} -{"date":"2026-03-20","commits":15,"loc_added":10671,"loc_removed":234,"releases":0,"contributors":["Felipe Rosa","Genie","Luís Sousa"]} -{"date":"2026-03-21","commits":8,"loc_added":609,"loc_removed":436,"releases":0,"contributors":["Felipe Rosa","Genie","Luís Sousa"]} -{"date":"2026-03-22","commits":46,"loc_added":4065,"loc_removed":1256,"releases":0,"contributors":["Felipe Rosa","Genie","Luís Sousa"]} -{"date":"2026-03-23","commits":33,"loc_added":7093,"loc_removed":2792,"releases":5,"contributors":["Felipe Rosa","Genie","github-actions[bot]"]} -{"date":"2026-03-24","commits":66,"loc_added":6248,"loc_removed":888,"releases":20,"contributors":["Automagik Genie 🧞","Felipe Rosa","Genie","github-actions[bot]"]} -{"date":"2026-03-25","commits":133,"loc_added":16626,"loc_removed":3927,"releases":34,"contributors":["Automagik Genie 🧞","Claude","Felipe Rosa","Genie","github-actions[bot]"]} -{"date":"2026-03-26","commits":17,"loc_added":627,"loc_removed":177,"releases":3,"contributors":["Felipe Rosa","Genie","github-actions[bot]"]} -{"date":"2026-03-27","commits":32,"loc_added":6836,"loc_removed":5629,"releases":7,"contributors":["Felipe Rosa","Genie","Luís","github-actions[bot]"]} -{"date":"2026-03-28","commits":42,"loc_added":23738,"loc_removed":2763,"releases":15,"contributors":["Claude","Felipe Rosa","Genie","Luís","github-actions[bot]"]} -{"date":"2026-03-29","commits":84,"loc_added":12534,"loc_removed":2379,"releases":33,"contributors":["Claude","Felipe Rosa","Genie","github-actions[bot]"]} -{"date":"2026-03-30","commits":100,"loc_added":5574,"loc_removed":2602,"releases":26,"contributors":["Claude","Felipe Rosa","Genie","github-actions[bot]"]} -{"date":"2026-03-31","commits":76,"loc_added":4503,"loc_removed":5663,"releases":20,"contributors":["Cezar Vasconcelos","Claude","Felipe Rosa","Genie","Luís","Luís Sousa","github-actions[bot]"]} -{"date":"2026-04-01","commits":58,"loc_added":3754,"loc_removed":842,"releases":6,"contributors":["Claude","Felipe Rosa","Genie","Test","github-actions[bot]"]} -{"date":"2026-04-02","commits":84,"loc_added":3484,"loc_removed":1136,"releases":20,"contributors":["Claude","Felipe Rosa","Genie","Luís Sousa","Test","github-actions[bot]"]} -{"date":"2026-04-03","commits":42,"loc_added":2187,"loc_removed":338,"releases":4,"contributors":["Automagik Genie 🧞","Claude","Felipe Rosa","Genie","Test","Zakir Jiwani","github-actions[bot]"]} -{"date":"2026-04-04","commits":58,"loc_added":10390,"loc_removed":649,"releases":2,"contributors":["Claude","Felipe Rosa","Genie","Test","filipexyz","github-actions[bot]"]} -{"date":"2026-04-05","commits":109,"loc_added":22624,"loc_removed":2611,"releases":16,"contributors":["Claude","Felipe Rosa","Test","github-actions[bot]"]} -{"date":"2026-04-06","commits":28,"loc_added":1745,"loc_removed":225,"releases":5,"contributors":["Claude","Felipe Rosa","Test","github-actions[bot]"]} -{"date":"2026-04-07","commits":35,"loc_added":3865,"loc_removed":208,"releases":7,"contributors":["Cezar Vasconcelos","Claude","Felipe Rosa","Genie","Test","github-actions[bot]"]} -{"date":"2026-04-08","commits":35,"loc_added":3780,"loc_removed":2109,"releases":5,"contributors":["Claude","Felipe Rosa","Genie","filipexyz","genie","github-actions[bot]"]} -{"date":"2026-04-09","commits":73,"loc_added":9761,"loc_removed":1253,"releases":14,"contributors":["Felipe Rosa","Genie","Luís","Test","filipexyz","genie","github-actions[bot]"]} -{"date":"2026-04-10","commits":37,"loc_added":34969,"loc_removed":205,"releases":5,"contributors":["Felipe Rosa","Genie","Test","felipe","github-actions[bot]"]} -{"date":"2026-04-11","commits":12,"loc_added":81,"loc_removed":38,"releases":3,"contributors":["Claude","Felipe Rosa","genie","github-actions[bot]"]} -{"date":"2026-04-12","commits":14,"loc_added":2754,"loc_removed":3653,"releases":1,"contributors":["Claude","Felipe Rosa","Test","genie","github-actions[bot]"]} -{"date":"2026-04-13","commits":14,"loc_added":668,"loc_removed":70,"releases":0,"contributors":["Claude","Felipe Rosa","genie","github-actions[bot]"]} -{"date":"2026-04-14","commits":0,"loc_added":0,"loc_removed":0,"releases":0,"contributors":[]} -{"date":"2026-04-15","commits":0,"loc_added":0,"loc_removed":0,"releases":0,"contributors":[]} -{"date":"2026-04-17","commits":4,"loc_added":8,"loc_removed":8,"releases":2,"contributors":["Felipe Rosa","github-actions[bot]"]} -{"date":"2026-04-18","commits":1,"loc_added":0,"loc_removed":0,"releases":0,"contributors":["Felipe Rosa"]} -{"date":"2026-04-20","commits":26,"loc_added":18518,"loc_removed":164,"releases":4,"contributors":["Felipe Rosa","Genie","Genie (env-specialist)","felipe rosa","genie","github-actions[bot]"]} -{"date":"2026-04-21","commits":75,"loc_added":4539,"loc_removed":374,"releases":0,"contributors":["Felipe Rosa","Genie","Genie (env-specialist)","genie","github-actions[bot]","namastex888"]} -{"date":"2026-04-22","commits":8,"loc_added":70,"loc_removed":16,"releases":0,"contributors":["Felipe Rosa","github-actions[bot]"]} -{"date":"2026-04-23","commits":3,"loc_added":161,"loc_removed":56,"releases":0,"contributors":["Felipe Rosa"]} -{"date":"2026-04-24","commits":9,"loc_added":5702,"loc_removed":247,"releases":3,"contributors":["Felipe Rosa","github-actions[bot]"]} -{"date":"2026-04-25","commits":0,"loc_added":0,"loc_removed":0,"releases":0,"contributors":[]} -{"date":"2026-04-26","commits":0,"loc_added":0,"loc_removed":0,"releases":0,"contributors":[]} -{"date":"2026-04-27","commits":18,"loc_added":2887,"loc_removed":133,"releases":0,"contributors":["Felipe Rosa","Genie","github-actions[bot]"]} -{"date":"2026-04-28","commits":188,"loc_added":34312,"loc_removed":9674,"releases":0,"contributors":["Claude","Felipe","Felipe Rosa","Genie","github-actions[bot]"]} -{"date":"2026-04-30","commits":45,"loc_added":4429,"loc_removed":291,"releases":0,"contributors":["Felipe Rosa","Genie","github-actions[bot]"]} -{"date":"2026-05-01","commits":3,"loc_added":4,"loc_removed":4,"releases":0,"contributors":["Felipe Rosa","github-actions[bot]"]} -{"date":"2026-05-02","commits":0,"loc_added":0,"loc_removed":0,"releases":0,"contributors":[]} -{"date":"2026-05-03","commits":13,"loc_added":1679,"loc_removed":104,"releases":0,"contributors":["Felipe","Felipe Rosa","github-actions[bot]"]} +{"date":"2026-05-06","commits":0,"loc_added":0,"loc_removed":0,"releases":0,"contributors":[]} +{"date":"2026-05-07","commits":0,"loc_added":0,"loc_removed":0,"releases":0,"contributors":[]} +{"date":"2026-05-08","commits":0,"loc_added":0,"loc_removed":0,"releases":0,"contributors":[]} +{"date":"2026-05-09","commits":0,"loc_added":0,"loc_removed":0,"releases":0,"contributors":[]} +{"date":"2026-05-10","commits":0,"loc_added":0,"loc_removed":0,"releases":0,"contributors":[]} +{"date":"2026-05-11","commits":0,"loc_added":0,"loc_removed":0,"releases":0,"contributors":[]} +{"date":"2026-05-12","commits":0,"loc_added":0,"loc_removed":0,"releases":0,"contributors":[]} +{"date":"2026-05-13","commits":0,"loc_added":0,"loc_removed":0,"releases":0,"contributors":[]} +{"date":"2026-05-14","commits":0,"loc_added":0,"loc_removed":0,"releases":0,"contributors":[]} {"date":"2026-05-15","commits":0,"loc_added":0,"loc_removed":0,"releases":0,"contributors":[]} -{"date":"2026-05-16","commits":13,"loc_added":994,"loc_removed":44,"releases":0,"contributors":["Felipe","Felipe Rosa","github-actions[bot]","release-bot"]} +{"date":"2026-05-16","commits":0,"loc_added":0,"loc_removed":0,"releases":0,"contributors":[]} {"date":"2026-05-17","commits":0,"loc_added":0,"loc_removed":0,"releases":0,"contributors":[]} -{"date":"2026-05-18","commits":20,"loc_added":165,"loc_removed":47,"releases":0,"contributors":["Automagik Genie 🧞","Felipe","Felipe Rosa","github-actions[bot]","release-bot"]} +{"date":"2026-05-18","commits":0,"loc_added":0,"loc_removed":0,"releases":0,"contributors":[]} {"date":"2026-05-19","commits":0,"loc_added":0,"loc_removed":0,"releases":0,"contributors":[]} -{"date":"2026-05-20","commits":10,"loc_added":152,"loc_removed":50,"releases":0,"contributors":["Claude (Opus 4.7)","Felipe","Felipe Rosa","github-actions[bot]"]} +{"date":"2026-05-20","commits":0,"loc_added":0,"loc_removed":0,"releases":0,"contributors":[]} {"date":"2026-05-21","commits":0,"loc_added":0,"loc_removed":0,"releases":0,"contributors":[]} -{"date":"2026-05-22","commits":13,"loc_added":957,"loc_removed":232,"releases":0,"contributors":["Cezar","Felipe","Felipe Rosa","Genie Automagik","github-actions[bot]","namastex888"]} +{"date":"2026-05-22","commits":79,"loc_added":939693,"loc_removed":297,"releases":0,"contributors":["Cezar","Claude","Felipe","Felipe Rosa","Genie Automagik","github-actions[bot]","release-bot"]} +{"date":"2026-05-23","commits":13,"loc_added":104,"loc_removed":51,"releases":0,"contributors":["Claude","Felipe Rosa","github-actions[bot]","release-bot"]} +{"date":"2026-05-24","commits":6,"loc_added":224,"loc_removed":83,"releases":0,"contributors":["Claude","automagik-genie","github-actions[bot]","release-bot"]} +{"date":"2026-05-25","commits":7,"loc_added":28,"loc_removed":37,"releases":0,"contributors":["Claude","Felipe Rosa","github-actions[bot]","release-bot"]} +{"date":"2026-05-26","commits":1,"loc_added":13,"loc_removed":11,"releases":0,"contributors":["Claude"]} +{"date":"2026-05-27","commits":4,"loc_added":33,"loc_removed":20,"releases":0,"contributors":["Claude","github-actions[bot]","release-bot"]} +{"date":"2026-05-28","commits":4,"loc_added":29,"loc_removed":39,"releases":0,"contributors":["Claude","github-actions[bot]","release-bot"]} +{"date":"2026-05-29","commits":0,"loc_added":0,"loc_removed":0,"releases":0,"contributors":[]} +{"date":"2026-05-30","commits":4,"loc_added":22,"loc_removed":20,"releases":0,"contributors":["Claude","github-actions[bot]","release-bot"]} +{"date":"2026-05-31","commits":4,"loc_added":16,"loc_removed":14,"releases":0,"contributors":["Claude","github-actions[bot]","release-bot"]} +{"date":"2026-06-01","commits":4,"loc_added":26,"loc_removed":104,"releases":0,"contributors":["Felipe Rosa","github-actions[bot]","release-bot"]} +{"date":"2026-06-02","commits":4,"loc_added":53,"loc_removed":29,"releases":0,"contributors":["Claude","github-actions[bot]","release-bot"]} +{"date":"2026-06-03","commits":4,"loc_added":26,"loc_removed":32,"releases":0,"contributors":["Claude","github-actions[bot]","release-bot"]} +{"date":"2026-06-04","commits":2,"loc_added":22,"loc_removed":16,"releases":0,"contributors":["Claude","github-actions[bot]"]} +{"date":"2026-06-05","commits":0,"loc_added":0,"loc_removed":0,"releases":0,"contributors":[]} +{"date":"2026-06-06","commits":0,"loc_added":0,"loc_removed":0,"releases":0,"contributors":[]} diff --git a/.genie/agents/metrics-updater/runs.jsonl b/.genie/agents/metrics-updater/runs.jsonl index 8fa4883ee..0bc1e6c52 100644 --- a/.genie/agents/metrics-updater/runs.jsonl +++ b/.genie/agents/metrics-updater/runs.jsonl @@ -1,65 +1,9 @@ -{"timestamp":"2026-03-24T22:44:08Z","duration_ms":4685,"api_calls":2,"tools_generated":0,"tools_available":5,"errors":[],"status":"success","fallback":false,"slowest_step":"unknown","steps":[],"metrics":{"releases_per_day":0,"avg_bugfix_time_hours":2.0,"ship_rate_pct":100.0,"parallel_agents":3,"updated":"2026-03-24"}} -{"timestamp": "2026-03-24T22:54:18Z", "duration_ms": 4579, "api_calls": 2, "tools_generated": 0, "tools_available": 10, "errors": [], "status": "success", "fallback": false, "slowest_step": "fetch_releases", "steps": [{"name": "load_state", "duration_ms": 31}, {"name": "fetch_releases", "duration_ms": 3023}, {"name": "fetch_prs", "duration_ms": 1218}, {"name": "count_agents", "duration_ms": 65}, {"name": "parse_metrics", "duration_ms": 126}, {"name": "update_readme", "duration_ms": 51}, {"name": "update_state", "duration_ms": 35}, {"name": "commit", "duration_ms": 4}], "metrics": {"releases_per_day": 0, "avg_bugfix_time_hours": 1.9, "ship_rate_pct": 100.0, "parallel_agents": 3, "updated": "2026-03-24"}} -{"timestamp": "2026-03-24T22:57:05Z", "duration_ms": 4292, "api_calls": 2, "tools_generated": 0, "tools_available": 10, "errors": [], "status": "success", "fallback": false, "slowest_step": "fetch_releases", "steps": [{"name": "load_state", "duration_ms": 35}, {"name": "fetch_releases", "duration_ms": 2778}, {"name": "fetch_prs", "duration_ms": 1149}, {"name": "count_agents", "duration_ms": 57}, {"name": "parse_metrics", "duration_ms": 118}, {"name": "update_readme", "duration_ms": 48}, {"name": "update_state", "duration_ms": 37}, {"name": "commit", "duration_ms": 45}], "metrics": {"releases_per_day": 0, "avg_bugfix_time_hours": 1.9, "ship_rate_pct": 100.0, "parallel_agents": 3, "updated": "2026-03-24"}} -{"timestamp": "2026-03-24T22:57:11Z", "duration_ms": 3833, "api_calls": 2, "tools_generated": 0, "tools_available": 10, "errors": [], "status": "no_changes", "fallback": false, "slowest_step": "fetch_releases", "steps": [{"name": "load_state", "duration_ms": 31}, {"name": "fetch_releases", "duration_ms": 2640}, {"name": "fetch_prs", "duration_ms": 872}, {"name": "count_agents", "duration_ms": 53}, {"name": "parse_metrics", "duration_ms": 124}, {"name": "update_readme", "duration_ms": 47}, {"name": "update_state", "duration_ms": 39}, {"name": "commit", "duration_ms": 3}], "metrics": {"releases_per_day": 0, "avg_bugfix_time_hours": 1.9, "ship_rate_pct": 100.0, "parallel_agents": 3, "updated": "2026-03-24"}} -{"timestamp": "2026-03-24T22:57:17Z", "duration_ms": 4637, "api_calls": 2, "tools_generated": 0, "tools_available": 10, "errors": [], "status": "no_changes", "fallback": false, "slowest_step": "fetch_releases", "steps": [{"name": "load_state", "duration_ms": 31}, {"name": "fetch_releases", "duration_ms": 3344}, {"name": "fetch_prs", "duration_ms": 980}, {"name": "count_agents", "duration_ms": 54}, {"name": "parse_metrics", "duration_ms": 122}, {"name": "update_readme", "duration_ms": 45}, {"name": "update_state", "duration_ms": 33}, {"name": "commit", "duration_ms": 3}], "metrics": {"releases_per_day": 0, "avg_bugfix_time_hours": 1.9, "ship_rate_pct": 100.0, "parallel_agents": 3, "updated": "2026-03-24"}} -{"timestamp": "2026-03-24T22:57:24Z", "duration_ms": 4067, "api_calls": 2, "tools_generated": 0, "tools_available": 10, "errors": [], "status": "no_changes", "fallback": false, "slowest_step": "fetch_releases", "steps": [{"name": "load_state", "duration_ms": 37}, {"name": "fetch_releases", "duration_ms": 2732}, {"name": "fetch_prs", "duration_ms": 1008}, {"name": "count_agents", "duration_ms": 55}, {"name": "parse_metrics", "duration_ms": 123}, {"name": "update_readme", "duration_ms": 45}, {"name": "update_state", "duration_ms": 38}, {"name": "commit", "duration_ms": 3}], "metrics": {"releases_per_day": 0, "avg_bugfix_time_hours": 1.9, "ship_rate_pct": 100.0, "parallel_agents": 3, "updated": "2026-03-24"}} -{"timestamp": "2026-03-24T22:57:30Z", "duration_ms": 4728, "api_calls": 2, "tools_generated": 0, "tools_available": 10, "errors": [], "status": "no_changes", "fallback": false, "slowest_step": "fetch_releases", "steps": [{"name": "load_state", "duration_ms": 31}, {"name": "fetch_releases", "duration_ms": 3211}, {"name": "fetch_prs", "duration_ms": 1186}, {"name": "count_agents", "duration_ms": 59}, {"name": "parse_metrics", "duration_ms": 126}, {"name": "update_readme", "duration_ms": 52}, {"name": "update_state", "duration_ms": 34}, {"name": "commit", "duration_ms": 3}], "metrics": {"releases_per_day": 0, "avg_bugfix_time_hours": 1.9, "ship_rate_pct": 100.0, "parallel_agents": 3, "updated": "2026-03-24"}} -{"timestamp": "2026-03-24T22:57:37Z", "duration_ms": 4374, "api_calls": 2, "tools_generated": 0, "tools_available": 10, "errors": [], "status": "no_changes", "fallback": false, "slowest_step": "fetch_releases", "steps": [{"name": "load_state", "duration_ms": 34}, {"name": "fetch_releases", "duration_ms": 2985}, {"name": "fetch_prs", "duration_ms": 1063}, {"name": "count_agents", "duration_ms": 53}, {"name": "parse_metrics", "duration_ms": 131}, {"name": "update_readme", "duration_ms": 47}, {"name": "update_state", "duration_ms": 34}, {"name": "commit", "duration_ms": 3}], "metrics": {"releases_per_day": 0, "avg_bugfix_time_hours": 1.9, "ship_rate_pct": 100.0, "parallel_agents": 3, "updated": "2026-03-24"}} -{"timestamp": "2026-03-24T22:57:43Z", "duration_ms": 4201, "api_calls": 2, "tools_generated": 0, "tools_available": 10, "errors": [], "status": "no_changes", "fallback": false, "slowest_step": "fetch_releases", "steps": [{"name": "load_state", "duration_ms": 32}, {"name": "fetch_releases", "duration_ms": 2704}, {"name": "fetch_prs", "duration_ms": 1172}, {"name": "count_agents", "duration_ms": 65}, {"name": "parse_metrics", "duration_ms": 122}, {"name": "update_readme", "duration_ms": 43}, {"name": "update_state", "duration_ms": 35}, {"name": "commit", "duration_ms": 3}], "metrics": {"releases_per_day": 0, "avg_bugfix_time_hours": 1.9, "ship_rate_pct": 100.0, "parallel_agents": 3, "updated": "2026-03-24"}} -{"timestamp": "2026-03-25T00:03:05Z", "duration_ms": 5882, "api_calls": 2, "tools_generated": 0, "tools_available": 10, "errors": [], "status": "success", "fallback": false, "slowest_step": "fetch_releases", "steps": [{"name": "load_state", "duration_ms": 33}, {"name": "fetch_releases", "duration_ms": 3458}, {"name": "fetch_prs", "duration_ms": 1046}, {"name": "count_agents", "duration_ms": 53}, {"name": "parse_metrics", "duration_ms": 1191}, {"name": "update_readme", "duration_ms": 45}, {"name": "update_state", "duration_ms": 30}, {"name": "commit", "duration_ms": 4}], "metrics": {"releases_per_day": 0, "avg_bugfix_time_hours": 1.7, "ship_rate_pct": 100.0, "loc_changed_24h": 7418, "commits_24h": 66, "prs_24h": 28, "parallel_agents": 3, "updated": "2026-03-25T00:03:04Z"}} -{"timestamp":"2026-03-25T00:00:00Z","duration_ms":31579683120,"api_calls":3,"tools_generated":1,"errors":0,"metrics":{"releases_24h":0,"avg_merge_time_hours":0.7,"ship_rate_pct":96,"merged_prs_7d":48}} -{"timestamp":"2026-03-28T00:00:00Z","duration_ms":45000,"api_calls":3,"tools_generated":0,"errors":[],"metrics":{"releases_24h":0,"avg_merge_time_hours":2.9,"ship_rate_pct":94,"merged_prs_7d":47}} -{"timestamp":"2026-03-29T00:00:00Z","duration_ms":52000,"api_calls":2,"tools_generated":0,"errors":[],"metrics":{"releases_24h":0,"avg_merge_time_hours":3.0,"ship_rate_pct":92,"merged_prs_7d":46}} -{"timestamp":"2026-03-30T00:00:00Z","duration_ms":5000,"api_calls":3,"tools_generated":0,"errors":0,"metrics":{"releases_24h":0,"releases_per_day":0,"avg_merge_time_hours":0.3,"ship_rate_pct":84,"merged_prs_7d":32,"closed_prs_7d":38}} -{"timestamp":"2026-03-31T00:00:00Z","duration_ms":5002,"api_calls":2,"tools_generated":4,"errors":0,"metrics":{"releases_24h":0,"merged_prs_7d":35,"avg_merge_time_h":0.3,"ship_rate_pct":85}} -{"timestamp":"2026-04-01T00:00:00Z","duration_ms":8000,"api_calls":0,"tools_generated":0,"errors":["gh CLI unavailable","GitHub MCP tools not loaded in session"],"status":"failed","fallback":false,"metrics":null} -{"timestamp":"2026-04-02T00:00:00.000Z","duration_ms":15000,"api_calls":0,"tools_generated":0,"errors":["gh CLI not available","GitHub MCP tools not present in session"],"status":"failed","fallback":false,"metrics":null} -{"timestamp":"2026-04-03T00:00:00.000Z","duration_ms":3200,"api_calls":0,"tools_generated":0,"errors":["gh CLI not available","GitHub MCP tools not present in session"],"status":"failed","fallback":false,"metrics":null} -{"timestamp":"2026-04-04T00:00:00.000Z","duration_ms":30000,"api_calls":0,"tools_generated":0,"errors":["gh CLI not available","GitHub MCP tools not present in session"],"status":"success","fallback":true,"metrics":{"releases_24h":2,"merged_prs_7d":35,"avg_merge_time_h":0.3,"ship_rate_pct":85}} -{"timestamp":"2026-04-05T12:24:47Z","duration_ms":32000,"api_calls":0,"tools_generated":0,"errors":["gh CLI not available","GitHub MCP tools not present in session"],"status":"success","fallback":true,"metrics":{"releases_24h":2,"merged_prs_7d":35,"avg_merge_time_h":0.3,"ship_rate_pct":85}} -{"timestamp":"2026-04-06T12:29:47.000Z","duration_ms":12500,"api_calls":0,"tools_generated":1,"errors":["gh_cli_unavailable: fallback to last_metrics from state.json"],"metrics":{"releases_24h":2,"merged_prs_7d":35,"avg_merge_time_h":0.3,"ship_rate_pct":85}} -{"timestamp":"2026-04-07T12:04:30Z","duration_ms":27000,"api_calls":0,"tools_generated":0,"errors":["gh CLI not available","no GitHub token or MCP GitHub tools present in session"],"status":"failed","fallback":false,"metrics":null} -{"timestamp":"2026-04-08T00:00:00.000Z","duration_ms":18000,"api_calls":0,"tools_generated":0,"errors":["gh CLI not available","GitHub MCP tools not present in session","fallback data older than current README — README update skipped"],"status":"failed","fallback":false,"metrics":null} -{"timestamp":"2026-04-11T12:35:40Z","duration_ms":72153,"api_calls":0,"tools_generated":0,"errors":["gh CLI not available","GitHub MCP tools not present in session"],"status":"success","fallback":true,"metrics":{"releases_24h":2,"merged_prs_7d":35,"avg_merge_time_h":0.3,"ship_rate_pct":85}} -{"timestamp":"2026-04-12T00:00:00.000Z","duration_ms":30000,"api_calls":2,"tools_generated":0,"errors":[],"status":"success","fallback":false,"metrics":{"releases_24h":0,"merged_prs_7d":5,"avg_merge_time_h":2.5,"ship_rate_pct":100}} -{"timestamp": "2026-04-12T18:38:51Z", "duration_ms": 732, "status": "success", "dry_run": true, "daily_stats_count": 31, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 93}, {"name": "backfill_check", "duration_ms": 22}, {"name": "generate_charts", "duration_ms": 62}, {"name": "generate_velocity", "duration_ms": 336}, {"name": "generate_readme", "duration_ms": 66}, {"name": "commit_push", "duration_ms": 19}]} -{"timestamp": "2026-04-12T19:41:00Z", "duration_ms": 817, "status": "success", "dry_run": true, "daily_stats_count": 31, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 110}, {"name": "backfill_check", "duration_ms": 22}, {"name": "generate_charts", "duration_ms": 68}, {"name": "generate_velocity", "duration_ms": 372}, {"name": "generate_readme", "duration_ms": 73}, {"name": "commit_push", "duration_ms": 20}]} -{"timestamp":"2026-04-13T00:00:00.000Z","duration_ms":45000,"api_calls":2,"tools_generated":0,"errors":[],"status":"success","fallback":false,"metrics":{"releases_24h":0,"merged_prs_7d":9,"avg_merge_time_h":1.4,"ship_rate_pct":100}} -{"timestamp": "2026-04-14T12:09:00Z", "duration_ms": 1264, "status": "success", "dry_run": true, "daily_stats_count": 33, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 216}, {"name": "backfill_check", "duration_ms": 47}, {"name": "generate_charts", "duration_ms": 158}, {"name": "generate_velocity", "duration_ms": 359}, {"name": "generate_readme", "duration_ms": 159}, {"name": "commit_push", "duration_ms": 33}]} -{"timestamp": "2026-04-14T12:09:00Z", "duration_ms": 1576, "api_calls": 2, "tools_generated": 3, "errors": [], "metrics": {"releases_24h": 1, "merged_prs_7d": 12, "avg_merge_time_h": 10.4, "ship_rate_pct": 100, "git_commits_7d": 185, "git_releases_7d": 28, "git_loc_net_7d": 44700, "git_contributors_7d": 6, "daily_stats_count": 33, "charts_generated": 3}} -{"timestamp": "2026-04-15T12:06:31Z", "duration_ms": 1350, "status": "partial", "dry_run": false, "daily_stats_count": 34, "charts_generated": 3, "velocity_md_updated": true, "errors": ["git push failed"], "steps": [{"name": "collect_stats", "duration_ms": 155}, {"name": "backfill_check", "duration_ms": 40}, {"name": "generate_charts", "duration_ms": 123}, {"name": "generate_velocity", "duration_ms": 267}, {"name": "generate_readme", "duration_ms": 115}, {"name": "commit_push", "duration_ms": 422}]} -{"timestamp": "2026-04-15T12:06:31Z", "duration_ms": 1642, "api_calls": 2, "tools_generated": 3, "errors": ["git push failed (HTTP 403)"], "metrics": {"releases_24h": 2, "merged_prs_7d": 13, "avg_merge_time_h": 9.7, "ship_rate_pct": 93}} -{"timestamp": "2026-04-17T12:18:01Z", "duration_ms": 7320, "status": "success", "dry_run": false, "daily_stats_count": 35, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 532}, {"name": "backfill_check", "duration_ms": 43}, {"name": "generate_charts", "duration_ms": 143}, {"name": "generate_velocity", "duration_ms": 3536}, {"name": "generate_readme", "duration_ms": 153}, {"name": "commit_push", "duration_ms": 2638}]} -{"timestamp": "2026-04-18T12:17:47Z", "duration_ms": 1331, "status": "partial", "dry_run": false, "daily_stats_count": 36, "charts_generated": 3, "velocity_md_updated": true, "errors": ["git push failed"], "steps": [{"name": "collect_stats", "duration_ms": 166}, {"name": "backfill_check", "duration_ms": 37}, {"name": "generate_charts", "duration_ms": 123}, {"name": "generate_velocity", "duration_ms": 243}, {"name": "generate_readme", "duration_ms": 109}, {"name": "commit_push", "duration_ms": 427}]} -{"timestamp": "2026-04-18T12:18:40Z", "duration_ms": 1643, "status": "success", "dry_run": false, "daily_stats_count": 36, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "metrics": {"releases_24h": 0, "merged_prs_7d": 30, "avg_merge_time_h": 4.8, "ship_rate_pct": 97}, "steps": [{"name": "collect_stats", "duration_ms": 166}, {"name": "backfill_check", "duration_ms": 37}, {"name": "generate_charts", "duration_ms": 123}, {"name": "generate_velocity", "duration_ms": 243}, {"name": "generate_readme", "duration_ms": 109}, {"name": "commit_push", "duration_ms": 427}, {"name": "update_state", "duration_ms": 310}]} -{"timestamp": "2026-04-20T12:30:08Z", "duration_ms": 7496, "status": "success", "dry_run": false, "daily_stats_count": 37, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 646}, {"name": "backfill_check", "duration_ms": 47}, {"name": "generate_charts", "duration_ms": 191}, {"name": "generate_velocity", "duration_ms": 3389}, {"name": "generate_readme", "duration_ms": 151}, {"name": "commit_push", "duration_ms": 2777}]} -{"timestamp": "2026-04-21T12:05:54Z", "duration_ms": 1346, "status": "success", "dry_run": true, "daily_stats_count": 38, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 324}, {"name": "backfill_check", "duration_ms": 48}, {"name": "generate_charts", "duration_ms": 155}, {"name": "generate_velocity", "duration_ms": 368}, {"name": "generate_readme", "duration_ms": 128}, {"name": "commit_push", "duration_ms": 39}]} -{"timestamp": "2026-04-21T12:05:54Z", "duration_ms": 1659, "api_calls": 2, "tools_generated": 3, "errors": [], "metrics": {"releases_24h": 0, "merged_prs_7d": 50, "avg_merge_time_h": 0.7, "ship_rate_pct": 100}} -{"timestamp": "2026-04-22T12:33:14Z", "duration_ms": 3400, "status": "partial", "dry_run": false, "daily_stats_count": 39, "charts_generated": 3, "velocity_md_updated": true, "errors": ["git push failed"], "steps": [{"name": "collect_stats", "duration_ms": 359}, {"name": "backfill_check", "duration_ms": 101}, {"name": "generate_charts", "duration_ms": 307}, {"name": "generate_velocity", "duration_ms": 714}, {"name": "generate_readme", "duration_ms": 302}, {"name": "commit_push", "duration_ms": 1054}]} -{"timestamp": "2026-04-23T12:19:17Z", "duration_ms": 979, "status": "success", "dry_run": true, "daily_stats_count": 40, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 170}, {"name": "backfill_check", "duration_ms": 36}, {"name": "generate_charts", "duration_ms": 120}, {"name": "generate_velocity", "duration_ms": 281}, {"name": "generate_readme", "duration_ms": 115}, {"name": "commit_push", "duration_ms": 28}]} -{"timestamp": "2026-04-23T12:20:13Z", "duration_ms": 42500, "status": "success", "dry_run": false, "api_calls": 4, "daily_stats_count": 40, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "metrics": {"releases_24h": 0, "merged_prs_7d": 142, "avg_merge_time_h": 0.7, "ship_rate_pct": 99}, "steps": [{"name": "fetch_github_releases", "duration_ms": 1200}, {"name": "fetch_github_prs_p1", "duration_ms": 3500}, {"name": "fetch_github_prs_p2", "duration_ms": 2800}, {"name": "collect_stats", "duration_ms": 170}, {"name": "generate_charts", "duration_ms": 120}, {"name": "generate_velocity", "duration_ms": 281}, {"name": "update_readme", "duration_ms": 115}, {"name": "commit_push", "duration_ms": 4200}]} -{"timestamp": "2026-04-24T12:25:20Z", "duration_ms": 4068, "status": "success", "dry_run": true, "daily_stats_count": 41, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 606}, {"name": "backfill_check", "duration_ms": 35}, {"name": "generate_charts", "duration_ms": 121}, {"name": "generate_velocity", "duration_ms": 2936}, {"name": "generate_readme", "duration_ms": 115}, {"name": "commit_push", "duration_ms": 28}]} -{"timestamp": "2026-04-24T12:25:33Z", "duration_ms": 6751, "status": "success", "dry_run": false, "daily_stats_count": 41, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 592}, {"name": "backfill_check", "duration_ms": 37}, {"name": "generate_charts", "duration_ms": 113}, {"name": "generate_velocity", "duration_ms": 3014}, {"name": "generate_readme", "duration_ms": 125}, {"name": "commit_push", "duration_ms": 2640}]} -{"timestamp":"2026-04-25T00:00:00Z","duration_ms":47500,"status":"success","dry_run":false,"daily_stats_count":42,"charts_generated":3,"velocity_md_updated":true,"errors":[],"metrics":{"releases_24h":0,"merged_prs_7d":95,"avg_merge_time_h":0.5,"ship_rate_pct":95,"commits_7d":121,"loc_added_7d":28100,"contributors_7d":6,"releases_7d":7},"steps":[{"name":"collect_stats","duration_ms":800},{"name":"generate_charts","duration_ms":1200},{"name":"generate_velocity","duration_ms":900},{"name":"generate_readme_hero","duration_ms":700},{"name":"github_api_releases","duration_ms":2100},{"name":"github_api_prs","duration_ms":4200}]} -{"timestamp": "2026-04-26T12:28:37Z", "duration_ms": 3024, "status": "success", "dry_run": false, "daily_stats_count": 43, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 37}, {"name": "backfill_check", "duration_ms": 14}, {"name": "generate_charts", "duration_ms": 48}, {"name": "generate_velocity", "duration_ms": 97}, {"name": "generate_readme", "duration_ms": 46}, {"name": "commit_push", "duration_ms": 2685}]} -{"timestamp": "2026-04-27T12:28:09Z", "duration_ms": 2286, "status": "success", "dry_run": false, "daily_stats_count": 44, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 67}, {"name": "backfill_check", "duration_ms": 14}, {"name": "generate_charts", "duration_ms": 96}, {"name": "generate_velocity", "duration_ms": 126}, {"name": "generate_readme", "duration_ms": 52}, {"name": "commit_push", "duration_ms": 1822}]} -{"timestamp": "2026-04-28T12:39:45Z", "duration_ms": 487, "status": "success", "dry_run": true, "daily_stats_count": 45, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 177}, {"name": "backfill_check", "duration_ms": 17}, {"name": "generate_charts", "duration_ms": 44}, {"name": "generate_velocity", "duration_ms": 101}, {"name": "generate_readme", "duration_ms": 46}, {"name": "commit_push", "duration_ms": 11}]} -{"timestamp": "2026-04-28T12:39:45Z", "duration_ms": 603, "status": "success", "dry_run": false, "daily_stats_count": 45, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "metrics": {"releases_24h": 0, "merged_prs_7d": 142, "avg_merge_time_h": 0.7, "ship_rate_pct": 99}, "steps": [{"name": "collect_stats", "duration_ms": 177}, {"name": "backfill_check", "duration_ms": 17}, {"name": "generate_charts", "duration_ms": 44}, {"name": "generate_velocity", "duration_ms": 101}, {"name": "generate_readme", "duration_ms": 46}, {"name": "commit_push", "duration_ms": 201}, {"name": "update_state", "duration_ms": 17}]} -{"timestamp": "2026-04-30T12:27:51Z", "duration_ms": 659, "status": "partial", "dry_run": false, "daily_stats_count": 46, "charts_generated": 3, "velocity_md_updated": true, "errors": ["git push failed"], "steps": [{"name": "collect_stats", "duration_ms": 70}, {"name": "backfill_check", "duration_ms": 15}, {"name": "generate_charts", "duration_ms": 48}, {"name": "generate_velocity", "duration_ms": 122}, {"name": "generate_readme", "duration_ms": 52}, {"name": "commit_push", "duration_ms": 255}]} -{"timestamp": "2026-05-01T12:21:31Z", "duration_ms": 618, "status": "success", "dry_run": true, "daily_stats_count": 47, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 79}, {"name": "backfill_check", "duration_ms": 23}, {"name": "generate_charts", "duration_ms": 117}, {"name": "generate_velocity", "duration_ms": 169}, {"name": "generate_readme", "duration_ms": 81}, {"name": "commit_push", "duration_ms": 14}]} -{"timestamp": "2026-05-01T12:21:48Z", "duration_ms": 2778, "status": "success", "dry_run": false, "daily_stats_count": 47, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 74}, {"name": "backfill_check", "duration_ms": 20}, {"name": "generate_charts", "duration_ms": 90}, {"name": "generate_velocity", "duration_ms": 207}, {"name": "generate_readme", "duration_ms": 138}, {"name": "commit_push", "duration_ms": 2074}]} -{"timestamp": "2026-05-02T12:24:53Z", "duration_ms": 2153, "status": "success", "dry_run": false, "daily_stats_count": 48, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 44}, {"name": "backfill_check", "duration_ms": 13}, {"name": "generate_charts", "duration_ms": 67}, {"name": "generate_velocity", "duration_ms": 109}, {"name": "generate_readme", "duration_ms": 44}, {"name": "commit_push", "duration_ms": 1787}]} -{"timestamp": "2026-05-03T12:15:02Z", "duration_ms": 3199, "status": "success", "dry_run": false, "daily_stats_count": 49, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 68}, {"name": "backfill_check", "duration_ms": 15}, {"name": "generate_charts", "duration_ms": 78}, {"name": "generate_velocity", "duration_ms": 142}, {"name": "generate_readme", "duration_ms": 53}, {"name": "commit_push", "duration_ms": 2733}]} -{"timestamp": "2026-05-15T12:06:37Z", "duration_ms": 773, "status": "partial", "dry_run": false, "daily_stats_count": 50, "charts_generated": 3, "velocity_md_updated": true, "errors": ["git push failed"], "steps": [{"name": "collect_stats", "duration_ms": 49}, {"name": "backfill_check", "duration_ms": 14}, {"name": "generate_charts", "duration_ms": 143}, {"name": "generate_velocity", "duration_ms": 187}, {"name": "generate_readme", "duration_ms": 62}, {"name": "commit_push", "duration_ms": 223}]} -{"timestamp":"2026-05-16T12:06:43Z","duration_ms":9240,"status":"success","dry_run":false,"api_calls":0,"daily_stats_count":51,"charts_generated":3,"velocity_md_updated":true,"errors":["gh CLI not available — used last_metrics fallback from state.json"],"metrics":{"releases_24h":0,"merged_prs_7d":142,"avg_merge_time_h":0.7,"ship_rate_pct":99,"commits_today":13,"loc_added_today":994,"loc_removed_today":44,"contributors_today":4},"steps":[{"name":"collect_stats","duration_ms":120},{"name":"backfill_check","duration_ms":18},{"name":"generate_charts","duration_ms":95},{"name":"generate_velocity","duration_ms":180},{"name":"generate_readme","duration_ms":65},{"name":"commit_push","duration_ms":8720}]} -{"timestamp": "2026-05-17T12:12:25Z", "duration_ms": 414, "status": "success", "dry_run": true, "daily_stats_count": 52, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 54}, {"name": "backfill_check", "duration_ms": 15}, {"name": "generate_charts", "duration_ms": 69}, {"name": "generate_velocity", "duration_ms": 103}, {"name": "generate_readme", "duration_ms": 51}, {"name": "commit_push", "duration_ms": 12}]} -{"timestamp": "2026-05-17T12:12:31Z", "duration_ms": 2794, "status": "success", "dry_run": false, "daily_stats_count": 52, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 43}, {"name": "backfill_check", "duration_ms": 16}, {"name": "generate_charts", "duration_ms": 52}, {"name": "generate_velocity", "duration_ms": 102}, {"name": "generate_readme", "duration_ms": 51}, {"name": "commit_push", "duration_ms": 2428}]} -{"timestamp": "2026-05-17T12:12:31Z", "duration_ms": 2928, "api_calls": 2, "tools_generated": 3, "errors": [], "metrics": {"releases_24h": 0, "merged_prs_7d": 32, "avg_merge_time_h": 4.2, "ship_rate_pct": 64}} -{"timestamp": "2026-05-18T12:03:46Z", "duration_ms": 608, "status": "success", "dry_run": true, "daily_stats_count": 53, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 79}, {"name": "backfill_check", "duration_ms": 13}, {"name": "generate_charts", "duration_ms": 51}, {"name": "generate_velocity", "duration_ms": 315}, {"name": "generate_readme", "duration_ms": 49}, {"name": "commit_push", "duration_ms": 11}]} -{"timestamp": "2026-05-18T12:05:06Z", "duration_ms": 726, "api_calls": 2, "tools_generated": 3, "errors": [], "metrics": {"releases_24h": 6, "merged_prs_7d": 41, "avg_merge_time_h": 3.3, "ship_rate_pct": 88}, "status": "success"} -{"timestamp": "2026-05-19T12:03:32Z", "duration_ms": 377, "status": "success", "dry_run": true, "daily_stats_count": 54, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 42}, {"name": "backfill_check", "duration_ms": 15}, {"name": "generate_charts", "duration_ms": 73}, {"name": "generate_velocity", "duration_ms": 97}, {"name": "generate_readme", "duration_ms": 45}, {"name": "commit_push", "duration_ms": 11}]} -{"timestamp": "2026-05-19T12:05:27Z", "duration_ms": 377, "api_calls": 2, "tools_generated": 3, "errors": [], "metrics": {"releases_24h": 1, "merged_prs_7d": 30, "avg_merge_time_h": 4.8, "ship_rate_pct": 97}, "status": "success"} -{"timestamp": "2026-05-20T12:08:49Z", "duration_ms": 2972, "status": "success", "dry_run": false, "daily_stats_count": 55, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 61}, {"name": "backfill_check", "duration_ms": 13}, {"name": "generate_charts", "duration_ms": 70}, {"name": "generate_velocity", "duration_ms": 97}, {"name": "generate_readme", "duration_ms": 53}, {"name": "commit_push", "duration_ms": 2578}]} -{"timestamp": "2026-05-21T12:06:07Z", "duration_ms": 3092, "status": "success", "dry_run": false, "daily_stats_count": 56, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 44}, {"name": "backfill_check", "duration_ms": 13}, {"name": "generate_charts", "duration_ms": 68}, {"name": "generate_velocity", "duration_ms": 84}, {"name": "generate_readme", "duration_ms": 43}, {"name": "commit_push", "duration_ms": 2750}]} -{"timestamp": "2026-05-22T12:04:19Z", "duration_ms": 2415, "status": "success", "dry_run": false, "daily_stats_count": 57, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 61}, {"name": "backfill_check", "duration_ms": 12}, {"name": "generate_charts", "duration_ms": 60}, {"name": "generate_velocity", "duration_ms": 91}, {"name": "generate_readme", "duration_ms": 64}, {"name": "commit_push", "duration_ms": 2039}]} +{"timestamp": "2026-05-22T12:04:19Z", "duration_ms": 2415, "status": "success", "dry_run": false, "daily_stats_count": 57, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": []} +{"timestamp": "2026-06-01T12:12:33Z", "duration_ms": 790, "status": "success", "dry_run": false, "daily_stats_count": 58, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 52}, {"name": "backfill_check", "duration_ms": 15}, {"name": "generate_charts", "duration_ms": 68}, {"name": "generate_velocity", "duration_ms": 96}, {"name": "generate_readme", "duration_ms": 54}, {"name": "commit_push", "duration_ms": 238}, {"name": "update_state", "duration_ms": 148}, {"name": "mcp_push", "duration_ms": 0}]} +{"timestamp": "2026-06-02T12:16:52Z", "duration_ms": 0, "status": "success", "dry_run": false, "daily_stats_count": 68, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "metrics": {"releases_24h": 1, "merged_prs_7d": 30, "avg_merge_time_h": 4.8, "ship_rate_pct": 97, "commits_7d": 20, "releases_7d": 5, "net_loc_7d": -71, "contributors_7d": 4}, "steps": [{"name": "collect_stats", "status": "success"}, {"name": "generate_charts", "status": "success"}, {"name": "generate_velocity", "status": "success"}, {"name": "generate_readme_hero", "status": "success"}, {"name": "git_commit_push", "status": "success"}, {"name": "github_pr_metrics", "status": "fallback", "note": "GitHub search returned 0 merged PRs in last 7d (team uses direct commits to dev); used state.json fallback values"}]} +{"timestamp": "2026-06-03T12:13:27Z", "duration_ms": 2660, "status": "success", "dry_run": false, "daily_stats_count": 69, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 47}, {"name": "backfill_check", "duration_ms": 14}, {"name": "generate_charts", "duration_ms": 72}, {"name": "generate_velocity", "duration_ms": 102}, {"name": "generate_readme", "duration_ms": 63}, {"name": "commit_push", "duration_ms": 2250}]} +{"timestamp": "2026-06-03T12:13:27Z", "duration_ms": 2775, "api_calls": 2, "tools_generated": 3, "errors": [], "status": "success", "dry_run": false, "daily_stats_count": 69, "charts_generated": 3, "velocity_md_updated": true, "metrics": {"releases_24h": 0, "releases_7d": 6, "releases_7d_rate_per_day": 0.86, "merged_prs_7d": 0, "avg_merge_time_h": 4.8, "ship_rate_pct": 97, "merged_prs_note": "GitHub API confirmed 0 merged PRs May 27-Jun 3; team uses direct commits to dev", "avg_merge_time_source": "fallback (state.json)", "ship_rate_source": "fallback (state.json)"}, "steps": [{"name": "collect_stats", "duration_ms": 47, "status": "success"}, {"name": "backfill_check", "duration_ms": 14, "status": "skipped", "note": "69 entries >= 30"}, {"name": "generate_charts", "duration_ms": 72, "status": "success"}, {"name": "generate_velocity", "duration_ms": 102, "status": "success"}, {"name": "generate_readme_hero", "duration_ms": 63, "status": "success"}, {"name": "git_commit_push", "duration_ms": 2250, "status": "success"}, {"name": "github_api_releases", "duration_ms": 350, "status": "success", "result": {"releases_24h": 0, "releases_7d": 6}}, {"name": "github_api_prs", "duration_ms": 420, "status": "fallback", "note": "GitHub API returned 0 merged PRs in last 7d; team uses direct commits to dev; using state.json fallback for avg_merge_time_h and ship_rate_pct"}, {"name": "update_state", "duration_ms": 114, "status": "success"}]} +{"timestamp": "2026-06-04T12:17:44Z", "duration_ms": 457, "status": "success", "dry_run": true, "daily_stats_count": 70, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 58}, {"name": "backfill_check", "duration_ms": 18}, {"name": "generate_charts", "duration_ms": 57}, {"name": "generate_velocity", "duration_ms": 120}, {"name": "generate_readme", "duration_ms": 63}, {"name": "commit_push", "duration_ms": 16}]} +{"timestamp":"2026-06-04T12:17:44Z","duration_ms":600,"status":"success","dry_run":false,"daily_stats_count":70,"charts_generated":3,"velocity_md_updated":true,"api_calls":2,"tools_generated":7,"errors":[],"metrics":{"releases_24h":1,"releases_7d":5,"merged_prs_7d":0,"avg_merge_time_h":4.8,"ship_rate_pct":97,"commits_7d":12,"loc_net_7d":-74,"contributors_7d":4},"steps":[{"name":"collect_stats","duration_ms":58},{"name":"backfill_check","duration_ms":18},{"name":"generate_charts","duration_ms":57},{"name":"generate_velocity","duration_ms":120},{"name":"generate_readme","duration_ms":63},{"name":"update_state","duration_ms":142}]} +{"timestamp": "2026-06-05T12:12:43Z", "duration_ms": 2887, "status": "success", "dry_run": false, "daily_stats_count": 31, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 48}, {"name": "backfill_check", "duration_ms": 16}, {"name": "generate_charts", "duration_ms": 54}, {"name": "generate_velocity", "duration_ms": 93}, {"name": "generate_readme", "duration_ms": 57}, {"name": "commit_push", "duration_ms": 2511}]} +{"timestamp": "2026-06-06T12:07:59Z", "duration_ms": 3490, "status": "success", "dry_run": false, "daily_stats_count": 32, "charts_generated": 3, "velocity_md_updated": true, "errors": [], "steps": [{"name": "collect_stats", "duration_ms": 72}, {"name": "backfill_check", "duration_ms": 18}, {"name": "generate_charts", "duration_ms": 119}, {"name": "generate_velocity", "duration_ms": 133}, {"name": "generate_readme", "duration_ms": 59}, {"name": "commit_push", "duration_ms": 2963}]} diff --git a/.genie/agents/metrics-updater/state.json b/.genie/agents/metrics-updater/state.json index 40218c722..5d8df868d 100644 --- a/.genie/agents/metrics-updater/state.json +++ b/.genie/agents/metrics-updater/state.json @@ -1,14 +1,19 @@ { - "last_run": "2026-05-22T12:04:19Z", + "last_run": "2026-06-06T12:07:59Z", "last_run_status": "success", "last_metrics": { "releases_24h": 1, - "merged_prs_7d": 30, + "releases_7d": 5, + "merged_prs_7d": 0, "avg_merge_time_h": 4.8, - "ship_rate_pct": 97 + "ship_rate_pct": 97, + "commits_7d": 12, + "loc_net_7d": -74, + "contributors_7d": 4, + "note": "merged_prs_7d=0: team uses direct commits to dev branch (confirmed by GitHub API)" }, - "daily_stats_count": 57, + "daily_stats_count": 32, "charts_generated": 3, "velocity_md_updated": true, - "duration_ms": 2415 + "duration_ms": 3490 } diff --git a/.genie/assets/commits-30d.svg b/.genie/assets/commits-30d.svg index 48a12ab0d..b349ba309 100644 --- a/.genie/assets/commits-30d.svg +++ b/.genie/assets/commits-30d.svg @@ -2,42 +2,42 @@ Commits / Day (30d) -188 +79 0 -94 - -04-09 - - - - +39 + +05-08 + + + + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - -05-22 + +06-06 \ No newline at end of file diff --git a/.genie/assets/loc-30d.svg b/.genie/assets/loc-30d.svg index a3935db19..38dbc1af9 100644 --- a/.genie/assets/loc-30d.svg +++ b/.genie/assets/loc-30d.svg @@ -6,52 +6,38 @@ added removed -44.0K +940.0K 0 -22.0K - - -04-09 - - - - - - - - - - - - - - - - - - - - - - - - - - +470.0K +05-08 + + + + + + + + + + - - - - - - - - - - -05-22 + + + + + + + + + + + + + + +06-06 \ No newline at end of file diff --git a/.genie/assets/releases-30d.svg b/.genie/assets/releases-30d.svg index afef1067f..56216a2b9 100644 --- a/.genie/assets/releases-30d.svg +++ b/.genie/assets/releases-30d.svg @@ -2,27 +2,27 @@ Releases / Day (30d) -14 +1 0 -7 - -04-09 - - - +0 + +05-08 + + + - + - + - + @@ -39,5 +39,5 @@ -05-22 +06-06 \ No newline at end of file diff --git a/README.md b/README.md index cee304971..d5941bf3b 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ --> -**🚀 56 commits** this week · **0 releases** · **+1.9K LoC** · **9 contributors** +**🚀 18 commits** this week · **0 releases** · **-52 LoC** · **4 contributors** ![Commits per day (30d, all branches)](.genie/assets/commits-30d.svg) @@ -235,4 +235,4 @@ Genie ships a single, dark-only color palette inspired by **Severance** — the

You describe the problem. Genie does everything else. -

+

\ No newline at end of file diff --git a/VELOCITY.md b/VELOCITY.md index 8b9d6a258..5fdddfaa3 100644 --- a/VELOCITY.md +++ b/VELOCITY.md @@ -1,15 +1,15 @@ # Velocity Dashboard -> Last updated: 2026-05-22 12:04 UTC | Data range: 2026-03-13 to 2026-05-22 +> Last updated: 2026-06-06 12:07 UTC | Data range: 2026-05-06 to 2026-06-06 ## At a Glance | Metric | 7 days | 30 days | All time | |--------|-------:|--------:|---------:| -| Commits | 56 | 335 | 172 | -| Releases | 0 | 3 | 0 | -| LoC (net) | +1.9K | +40.6K | — | -| Contributors | 9 | 10 | 12 | +| Commits | 18 | 136 | 136 | +| Releases | 0 | 0 | 0 | +| LoC (net) | -52 | +939.5K | — | +| Contributors | 4 | 8 | 8 | --- @@ -31,15 +31,13 @@ | Rank | Contributor | Commits (approx) | |-----:|-------------|------------------:| -| 1 | Felipe Rosa | 81 | -| 2 | github-actions[bot] | 78 | -| 3 | Genie | 62 | -| 4 | Felipe | 52 | -| 5 | Claude | 37 | -| 6 | release-bot | 7 | -| 7 | Claude (Opus 4.7) | 2 | -| 8 | Cezar | 2 | -| 9 | Genie Automagik | 2 | -| 10 | namastex888 | 2 | - -*12 contributors since 2026-05-11* +| 1 | Claude | 24 | +| 2 | github-actions[bot] | 24 | +| 3 | release-bot | 23 | +| 4 | Felipe Rosa | 16 | +| 5 | Cezar | 11 | +| 6 | Felipe | 11 | +| 7 | Genie Automagik | 11 | +| 8 | automagik-genie | 1 | + +*8 contributors since 2026-05-22* diff --git a/package.json b/package.json index bdd46ea12..1977e33e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@automagik/genie", - "version": "4.260522.20", + "version": "4.260606.2", "description": "Collaborative terminal toolkit for human + AI workflows. NOTE: npm distribution discontinued 2026-05-09 — install via `curl -fsSL https://raw.githubusercontent.com/automagik-dev/genie/main/install.sh | bash` (cosign + SLSA verified). See https://automagik.dev/genie/release-process", "type": "module", "bin": { diff --git a/plugins/genie/.claude-plugin/plugin.json b/plugins/genie/.claude-plugin/plugin.json index d1d1f7ba3..c711e612c 100644 --- a/plugins/genie/.claude-plugin/plugin.json +++ b/plugins/genie/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genie", - "version": "4.260522.20", + "version": "4.260606.2", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.", "author": { "name": "Namastex Labs" diff --git a/plugins/genie/package.json b/plugins/genie/package.json index ddbaa8a9e..a0118d35d 100644 --- a/plugins/genie/package.json +++ b/plugins/genie/package.json @@ -1,6 +1,6 @@ { "name": "genie-plugin", - "version": "4.260522.20", + "version": "4.260606.2", "private": true, "description": "Runtime dependencies for genie bundled CLIs", "type": "module", diff --git a/scripts/tests/omni-spawn-smoke.ts b/scripts/tests/omni-spawn-smoke.ts new file mode 100644 index 000000000..ff1fbd904 --- /dev/null +++ b/scripts/tests/omni-spawn-smoke.ts @@ -0,0 +1,162 @@ +#!/usr/bin/env bun +/** + * Smoke test for issue #2486 — Omni-Bridge spawn command corruption. + * + * Validates that the NEW script-based path (writeTmuxLaunchScript + source) + * works reliably with long commands containing backticks, emojis, parentheses, + * and nested quotes. + * + * Run with: + * bun run scripts/tests/omni-spawn-smoke.ts + */ + +import { execSync } from 'node:child_process'; +import { readFileSync, unlinkSync } from 'node:fs'; +import { writeTmuxLaunchScript } from '../../src/lib/tmux-launch-script.js'; + +const MARKER = 'OMNI_SPAWN_SUCCESS_2486'; +const RESULT_FILE = `/tmp/${MARKER}`; + +// Build a deliberately nasty payload with all the problematic chars. +// We wrap everything in a single 'sh -c' so writeTmuxLaunchScript's +// leading 'exec' works correctly (the real Genie launches 'claude', +// a long-running process; here we use 'sleep' to keep the pane alive +// long enough for capture). +const nastyPayload = '👍 `backticks` (instance: alpha) (ALWAYS your last action)'; +const innerCommand = [ + `echo '${MARKER}'`, + `export TEST_VAR='${nastyPayload}'`, + `export JSON='{"emoji":"👍","nested":"(instance: alpha)"}'`, + `export LONG='${'A'.repeat(1800)}'`, + `touch ${RESULT_FILE}`, + 'sleep 1', +].join(' && '); + +// The full command that Genie would pass to writeTmuxLaunchScript +const nastyCommand = `sh -c '${innerCommand.replace(/'/g, "'\\''")}'`; + +function killServer(socket: string) { + try { + execSync(`tmux -L ${socket} kill-server 2>/dev/null`, { stdio: 'ignore' }); + } catch { + // ignore + } +} + +function cleanup(socket: string, scriptPath?: string) { + killServer(socket); + try { + unlinkSync(RESULT_FILE); + } catch { + // ignore + } + if (scriptPath) { + try { + unlinkSync(scriptPath); + } catch { + // ignore + } + } +} + +function createPane(socket: string): string { + execSync(`tmux -L ${socket} new-session -d -e LC_ALL=C.UTF-8 -e LANG=C.UTF-8`, { stdio: 'ignore' }); + execSync('sleep 0.3'); + const paneId = execSync(`tmux -L ${socket} list-panes -F '#{pane_id}'`, { encoding: 'utf-8' }).trim(); + return paneId; +} + +function capturePane(socket: string, paneId: string): string { + try { + return execSync(`tmux -L ${socket} capture-pane -p -t '${paneId}' -S -10`, { encoding: 'utf-8' }); + } catch { + return ''; + } +} + +function waitForResult(socket: string, paneId: string, timeoutMs = 5000): boolean { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const content = capturePane(socket, paneId); + if (content.includes(MARKER)) return true; + if (content.includes('parse error')) return false; + execSync('sleep 0.2'); + } + return false; +} + +console.log('=== Omni-Bridge Spawn Smoke Test (#2486) ===\n'); + +// --------------------------------------------------------------------------- +// TEST 1: OLD inline path (replicates executeTmux -> send-keys without -l) +// --------------------------------------------------------------------------- +const socket1 = 'genie-smoke-old'; +console.log('TEST 1: Inline send-keys (old path)'); +cleanup(socket1); +const pane1 = createPane(socket1); + +// Replicate the OLD Genie path exactly: +// executeTmux(`send-keys -t '${paneId}' ${shellQuote(cmd)} Enter`) +const quotedOld = `'${nastyCommand.replace(/'/g, "'\\''")}'`; +try { + execSync(`tmux -L ${socket1} send-keys -t '${pane1}' ${quotedOld} Enter`); +} catch (e) { + console.log(` tmux send-keys itself failed: ${e instanceof Error ? e.message : e}`); +} + +const oldOk = waitForResult(socket1, pane1); +const oldContent = capturePane(socket1, pane1); +const oldHasParseError = oldContent.includes('parse error'); +console.log(` Parse error visible: ${oldHasParseError}`); +console.log(` Marker detected: ${oldOk}`); +console.log(` Result: ${oldOk ? 'PASS' : 'FAIL / FLAKY'}\n`); + +killServer(socket1); + +// --------------------------------------------------------------------------- +// TEST 2: NEW script-based path +// --------------------------------------------------------------------------- +const socket2 = 'genie-smoke-new'; +console.log('TEST 2: Script-based send-keys (writeTmuxLaunchScript + source)'); +cleanup(socket2); +const pane2 = createPane(socket2); + +const scriptPath = writeTmuxLaunchScript('smoke-2486', nastyCommand); +// Replicate the NEW Genie path: +// executeTmux(`send-keys -t '${paneId}' "source ${scriptPath}" Enter`) +execSync(`tmux -L ${socket2} send-keys -t '${pane2}' "source ${scriptPath}" Enter`); + +const newOk = waitForResult(socket2, pane2); +const newContent = capturePane(socket2, pane2); +const newHasParseError = newContent.includes('parse error'); +let markerOnDisk = false; +try { + markerOnDisk = readFileSync(RESULT_FILE).toString().trim() === ''; +} catch { + markerOnDisk = false; +} +console.log(` Parse error visible: ${newHasParseError}`); +console.log(` Marker in pane: ${newContent.includes(MARKER)}`); +console.log(` Marker file created: ${markerOnDisk}`); +console.log(` Result: ${newOk ? 'PASS' : 'FAIL'}\n`); + +cleanup(socket2, scriptPath); + +// --------------------------------------------------------------------------- +// Summary +// --------------------------------------------------------------------------- +console.log('=== Summary ==='); +if (!oldOk && newOk) { + console.log('✅ Fix validated: inline path fails, script path is stable.'); + process.exit(0); +} else if (oldOk && newOk) { + console.log('⚠️ Both paths passed on this machine (tmux/zsh may be tolerant for this payload).'); + console.log(' The script path is still the safer architectural choice for 1968+ char payloads.'); + process.exit(0); +} else if (!newOk) { + console.log('❌ New script path failed — investigate.'); + process.exit(1); +} else { + console.log('❓ Unexpected state — review output above.'); + process.exit(1); +} diff --git a/src/genie-commands/update.ts b/src/genie-commands/update.ts index a83836577..4cb2af4a5 100644 --- a/src/genie-commands/update.ts +++ b/src/genie-commands/update.ts @@ -20,6 +20,7 @@ import { import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { genieConfigExists, loadGenieConfig, saveGenieConfig } from '../lib/genie-config.js'; +import { extractPgservePortFromStatus } from '../lib/pgserve-status.js'; import { VERSION } from '../lib/version.js'; import { pm2ProcessNameCandidates, regenerateEcosystemConfig } from './install.js'; import { type CleanupReport, cleanupLegacyArtifacts, parseSkipCleanupFlag } from './legacy-cleanup.js'; @@ -1259,21 +1260,10 @@ export function extractPgserveSocketDirFromStatus(output: string): string | null return null; } -export function extractPgservePortFromStatus(output: string): string | null { - try { - const parsed = JSON.parse(output) as { - port?: unknown; - instance?: { port?: unknown }; - runtime?: { port?: unknown }; - }; - const rawPort = parsed.port ?? parsed.instance?.port ?? parsed.runtime?.port; - if (typeof rawPort === 'number' && Number.isFinite(rawPort)) return String(rawPort); - if (typeof rawPort === 'string' && rawPort.trim()) return rawPort.trim(); - } catch { - // best-effort diagnostics only; callers fall back to null. - } - return null; -} +// Moved to ../lib/pgserve-status.js so migration 002 can share the same +// nested-shape-tolerant parser. Re-exported here to preserve the public API +// (and existing update.test.ts import path). +export { extractPgservePortFromStatus }; function collectGenieProcessSnapshot(): string | null { const snapshot = safeExec('ps -axo pid,ppid,pgid,stat,pcpu,pmem,etime,command -r', 2000) diff --git a/src/lib/__tests__/tmux-launch-script.test.ts b/src/lib/__tests__/tmux-launch-script.test.ts new file mode 100644 index 000000000..b4ecc0624 --- /dev/null +++ b/src/lib/__tests__/tmux-launch-script.test.ts @@ -0,0 +1,62 @@ +/** + * Unit tests for tmux-launch-script.ts + */ + +import { beforeEach, describe, expect, test } from 'bun:test'; +import { existsSync, readFileSync, rmSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { writeTmuxLaunchScript } from '../tmux-launch-script.js'; + +const SPAWN_DIR = join(homedir(), '.genie', 'spawn-scripts'); + +describe('writeTmuxLaunchScript', () => { + beforeEach(() => { + // Clean up any leftover test scripts + try { + const files = require('node:fs').readdirSync(SPAWN_DIR); + for (const f of files) { + if (f.startsWith('test-') || f.startsWith('omni-')) { + rmSync(join(SPAWN_DIR, f)); + } + } + } catch { + // dir may not exist yet + } + }); + + test('creates a script with shebang and command', () => { + const path = writeTmuxLaunchScript('test-worker', 'echo hello'); + expect(existsSync(path)).toBe(true); + + const content = readFileSync(path, 'utf-8'); + expect(content).toStartWith('#!/bin/sh\n'); + expect(content).toInclude('echo hello\n'); + }); + + test('sanitizes workerId in filename', () => { + const path = writeTmuxLaunchScript('worker/with:bad@chars', 'echo hello'); + const basename = path.split('/').pop()!; + expect(basename).toMatch(/^worker-with-bad-chars-/); + expect(basename).toEndWith('.sh'); + }); + + test('creates script in ~/.genie/spawn-scripts', () => { + const path = writeTmuxLaunchScript('test-worker', 'echo hello'); + expect(path).toStartWith(SPAWN_DIR); + }); + + test('sets executable permissions', () => { + const path = writeTmuxLaunchScript('test-worker', 'echo hello'); + const stats = require('node:fs').statSync(path); + // Check owner-execute bit + expect(stats.mode & 0o100).toBe(0o100); + }); + + test('preserves complex commands with quotes and backticks', () => { + const cmd = `OMNI_API_KEY='sk-123' claude --permission-mode 'auto' --system-prompt 'Use \`git\` (👍) for (instance: x)'`; + const path = writeTmuxLaunchScript('omni-chat-123', cmd); + const content = readFileSync(path, 'utf-8'); + expect(content).toInclude(`${cmd}\n`); + }); +}); diff --git a/src/lib/agent-observability.test.ts b/src/lib/agent-observability.test.ts index f18a2ac3f..9dce044b6 100644 --- a/src/lib/agent-observability.test.ts +++ b/src/lib/agent-observability.test.ts @@ -123,6 +123,30 @@ describe('assessHealth (pure)', () => { expect(assessHealth(row, now).flags).not.toContain('stale_executor'); }); + test('agent_error fires for errored agent lifecycle even without recent tool errors', () => { + const row = baseRow(); + row.agentState = 'error'; + row.executorState = 'terminated'; + row.recentErrorCount = 0; + + const h = assessHealth(row); + + expect(h.flags).toContain('agent_error'); + expect(h.degraded).toBe(true); + }); + + test('agent_error fires for errored executor lifecycle even when agent row has not caught up', () => { + const row = baseRow(); + row.agentState = 'working'; + row.executorState = 'error'; + row.recentErrorCount = 0; + + const h = assessHealth(row); + + expect(h.flags).toContain('agent_error'); + expect(h.degraded).toBe(true); + }); + test('missing_session fires when current executor has no session linkage or claude_session_id anchor', () => { const row = baseRow(); row.sessionId = null; diff --git a/src/lib/agent-observability.ts b/src/lib/agent-observability.ts index 233768968..a6469c82e 100644 --- a/src/lib/agent-observability.ts +++ b/src/lib/agent-observability.ts @@ -12,8 +12,8 @@ * the join + 24h tool/usage aggregates server-side; this module: * * 1. Casts row types into a stable TS shape (camelCase, typed). - * 2. Computes derived health flags (`stale_executor`, `missing_session`, - * `missing_attribution`, `recent_failure`, `cost_spike`, + * 2. Computes derived health flags (`agent_error`, `stale_executor`, + * `missing_session`, `missing_attribution`, `recent_failure`, `cost_spike`, * `high_hook_latency`). * 3. Provides classifier filters (`agent` vs `harness`). * @@ -41,6 +41,7 @@ export const AGENT_OBSERVABILITY_SCHEMA_VERSION = 1; export type AgentClassification = 'agent' | 'harness'; export type HealthFlag = + | 'agent_error' | 'stale_executor' | 'missing_session' | 'missing_attribution' @@ -173,6 +174,14 @@ function latestScopedActivity(row: AgentObservabilityRow): number { export function assessHealth(row: AgentObservabilityRow, now: number = Date.now()): HealthAssessment { const flags: HealthFlag[] = []; + // agent_error: the lifecycle itself is terminal/failed. Do not rely only on + // recent tool-event errors: many harness failures transition the agent or + // executor to `error` without a scoped tool_events row, and observe surfaces + // must still report the row as degraded. + if (row.agentState === 'error' || row.executorState === 'error') { + flags.push('agent_error'); + } + // stale_executor: live state but no heartbeat in the staleness window. // Prefer the freshest activity signal available. Executor rows may miss // heartbeat writes while Claude is still actively emitting tool events. Since diff --git a/src/lib/isolation-guard.test.ts b/src/lib/isolation-guard.test.ts new file mode 100644 index 000000000..9f6850080 --- /dev/null +++ b/src/lib/isolation-guard.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +describe('isolated database setup fail-closed guard', () => { + test('does not return no-op cleanup when isolated DB setup fails', () => { + const source = readFileSync(join(process.cwd(), 'src/lib', `test-${'db'}.ts`), 'utf8'); + + expect(source).toContain('Unable to prepare isolated Genie test database'); + expect(source).toContain('Unable to create isolated Genie test database'); + expect(source).not.toContain('return async () => {};'); + }); +}); diff --git a/src/lib/pgserve-status.ts b/src/lib/pgserve-status.ts new file mode 100644 index 000000000..88cf283d7 --- /dev/null +++ b/src/lib/pgserve-status.ts @@ -0,0 +1,24 @@ +/** + * Shared parser for ` status --json` output. + * + * The port can appear at the top level (`port`) or nested under `instance.port` + * / `runtime.port` depending on the binary version. Consumers MUST tolerate all + * three shapes — reading only top-level `.port` silently null-resolves on hosts + * emitting the nested shape. Extracted here so both `genie update` diagnostics + * and migration 002 (canonical-port discovery) share one implementation. + */ +export function extractPgservePortFromStatus(output: string): string | null { + try { + const parsed = JSON.parse(output) as { + port?: unknown; + instance?: { port?: unknown }; + runtime?: { port?: unknown }; + }; + const rawPort = parsed.port ?? parsed.instance?.port ?? parsed.runtime?.port; + if (typeof rawPort === 'number' && Number.isFinite(rawPort)) return String(rawPort); + if (typeof rawPort === 'string' && rawPort.trim()) return rawPort.trim(); + } catch { + // best-effort diagnostics only; callers fall back to null. + } + return null; +} diff --git a/src/lib/test-db.ts b/src/lib/test-db.ts index 1eacc012f..d95bd9402 100644 --- a/src/lib/test-db.ts +++ b/src/lib/test-db.ts @@ -87,12 +87,16 @@ async function warmTestConnection(): Promise { */ export async function setupTestDatabase(): Promise<() => Promise> { // Under concurrent test load, pgserve may have died unexpectedly. If that - // happens, fall back to a no-op cleanup so describe.skipIf(!DB_AVAILABLE) - // can still make progress. + // happens, fail closed instead of returning a no-op cleanup: many tests run + // destructive setup such as `DELETE FROM agents` immediately after this + // helper returns. Continuing without a guaranteed isolated database can wipe + // the operator's live Genie registry. try { await ensurePgserve(); - } catch { - return async () => {}; + } catch (error) { + throw new Error( + `Unable to prepare isolated Genie test database: pgserve unavailable (${error instanceof Error ? error.message : String(error)})`, + ); } // Quiesce the emit.ts background flusher BEFORE we swap databases. @@ -109,15 +113,14 @@ export async function setupTestDatabase(): Promise<() => Promise> { try { await createTestDatabase(dbName); - } catch { + } catch (error) { // createTestDatabase failed (e.g. pgserve died, template DB missing). - // shutdownEmitter() already latched `shuttingDown = true`; if we return - // without resumeEmitter() the emitter stays quiesced for the remainder - // of this process, silently dropping every emit from every subsequent - // test file. Re-open admits before bailing so fallback no-op cleanup is - // truly side-effect free. + // Fail closed: returning a no-op cleanup here leaves GENIE_TEST_DB_NAME + // unset, so destructive test setup can run against the operator's live DB. resumeEmitter(); - return async () => {}; + throw new Error( + `Unable to create isolated Genie test database ${dbName}: ${error instanceof Error ? error.message : String(error)}`, + ); } // Point db.ts at the new database and force a rebuild. diff --git a/src/lib/tmux-launch-script.ts b/src/lib/tmux-launch-script.ts new file mode 100644 index 000000000..17cbc61e7 --- /dev/null +++ b/src/lib/tmux-launch-script.ts @@ -0,0 +1,29 @@ +/** + * Tmux Launch Script — Write a temporary shell script for complex tmux spawns. + * + * Long commands with nested quotes, backticks, emojis, and JSON escapes corrupt + * when passed through `tmux send-keys`. Writing the command to a script file and + * sourcing it from the pane removes the escaping surface and keeps the launch + * stable. + */ + +import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** + * Write a temporary launch script for complex tmux spawns. + * + * @param workerId — identifier used in the filename (e.g. agent or chat id) + * @param fullCommand — the complete shell command to execute + * @returns absolute path to the written script + */ +export function writeTmuxLaunchScript(workerId: string, fullCommand: string): string { + const dir = join(homedir(), '.genie', 'spawn-scripts'); + mkdirSync(dir, { recursive: true }); + const safeId = workerId.replace(/[^a-zA-Z0-9._-]/g, '-'); + const scriptPath = join(dir, `${safeId}-${Date.now().toString(36)}.sh`); + writeFileSync(scriptPath, `#!/bin/sh\n${fullCommand}\n`, { mode: 0o700 }); + chmodSync(scriptPath, 0o700); + return scriptPath; +} diff --git a/src/lib/tmux.ts b/src/lib/tmux.ts index 9cd5385f2..a76d885b2 100644 --- a/src/lib/tmux.ts +++ b/src/lib/tmux.ts @@ -664,7 +664,7 @@ export async function isPaneProcessRunning( const exec: ExecSyncFn = execSyncFn ?? ((await import('node:child_process')).execSync as ExecSyncFn); // Check direct children and grandchildren for the target process name const output = exec( - `pgrep -la -P ${panePid} 2>/dev/null; for cpid in $(pgrep -P ${panePid} 2>/dev/null); do pgrep -la -P "$cpid" 2>/dev/null; done; true`, + `pgrep -la -P ${panePid} 2>/dev/null; for cpid in $(pgrep -P ${panePid} 2>/dev/null); do pgrep -la -P "$cpid" 2>/dev/null; ps -p "$cpid" -o comm= 2>/dev/null; done; true`, { encoding: 'utf-8', timeout: 5000 }, ); return output.toLowerCase().includes(processName.toLowerCase()); diff --git a/src/migrations/steps/002-kill-embedded-pgserve-legacy.ts b/src/migrations/steps/002-kill-embedded-pgserve-legacy.ts index 7d07035eb..0152ccb93 100644 --- a/src/migrations/steps/002-kill-embedded-pgserve-legacy.ts +++ b/src/migrations/steps/002-kill-embedded-pgserve-legacy.ts @@ -1,29 +1,79 @@ /** * Migration 002 — kill legacy embedded pgserve listening on a non-canonical port. * - * Detection: a postgres process owned by the current user is listening on - * a port other than canonical 8432, AND canonical pgserve responds on - * 8432, AND no obvious user-intent override (env var GENIE_KEEP_LEGACY_PG=1). + * Detection: a postgres process owned by the current user is listening on a + * port OTHER than the one the canonical pgserve actually binds, AND that + * canonical pgserve is reachable, AND no user-intent override + * (env var GENIE_KEEP_LEGACY_PG=1). + * + * The canonical port is DISCOVERED from the autopg/pgserve binary at runtime — + * never hardcoded. The canonical backbone moved 8432 → 5432 in the + * autopg-v3 / socket-singleton cutover; a hardcoded 8432 here would (a) be a + * no-op on healthy 5432 hosts and, worse, (b) during a mixed window where a + * stray legacy postmaster is still live on 8432 alongside canonical 5432, + * mistake 8432 for canonical and STOP the real 5432 backbone. Discovering the + * port fixes both. When no canonical binary is installed / it can't report a + * port, this migration is a safe no-op: we never stop a postmaster we cannot + * positively distinguish from canonical. * * Fix: send graceful pg_ctl stop to the legacy process; if that fails, * SIGTERM. Migration 001 (must run first) ensures genie-serve no longer * spawns it, so it stays dead. */ -import { execSync } from 'node:child_process'; +import { execFileSync, execSync } from 'node:child_process'; +import { resolvePgserveBinary } from '../../lib/canonical-pgserve-binary.js'; +import { extractPgservePortFromStatus } from '../../lib/pgserve-status.js'; import type { MigrationContext } from '../discover.js'; export const id = '002-kill-embedded-pgserve-legacy'; export const description = 'Stop legacy embedded pgserve on non-canonical ports when canonical is healthy'; -const CANONICAL_PORT = 8432; - interface ListeningPg { pid: number; port: number; } +let canonicalPortCache: number | null | undefined; + +/** + * Resolve the port the canonical pgserve actually binds by asking the + * autopg/pgserve binary (` status --json` → `.port`). Memoized for the + * duration of the migration run. Returns null when no canonical binary is + * installed or it doesn't report a numeric port. + */ +function resolveCanonicalPort(): number | null { + if (canonicalPortCache !== undefined) return canonicalPortCache; + const bin = resolvePgserveBinary(); + if (!bin) { + canonicalPortCache = null; + return null; + } + try { + const out = execFileSync(bin, ['status', '--json'], { + encoding: 'utf8', + timeout: 5000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + // Tolerate top-level `port` AND nested `instance.port` / `runtime.port` + // shapes (shared with `genie update` diagnostics). Reading only `.port` + // would null-resolve on hosts emitting the nested shape, making this + // migration a permanent no-op there. + const portStr = extractPgservePortFromStatus(out); + const port = portStr === null ? Number.NaN : Number.parseInt(portStr, 10); + canonicalPortCache = Number.isFinite(port) && port > 0 ? port : null; + } catch { + canonicalPortCache = null; + } + return canonicalPortCache; +} + +/** Test-only: clear the memoized canonical port. */ +export function _resetCanonicalPortCache(): void { + canonicalPortCache = undefined; +} + function listListeningPgserve(): ListeningPg[] { try { const out = execSync('ss -tlnp', { stdio: ['ignore', 'pipe', 'pipe'] }).toString(); @@ -49,31 +99,41 @@ function listListeningPgserve(): ListeningPg[] { } } -function canonicalReachable(): boolean { +function canonicalReachable(port: number): boolean { try { - execSync(`pg_isready -h 127.0.0.1 -p ${CANONICAL_PORT}`, { stdio: 'pipe' }); + execSync(`pg_isready -h 127.0.0.1 -p ${port}`, { stdio: 'pipe' }); return true; } catch { return false; } } -function findLegacyEmbedded(): ListeningPg | undefined { - if (process.env.GENIE_KEEP_LEGACY_PG === '1') return undefined; - if (!canonicalReachable()) return undefined; - return listListeningPgserve().find((p) => p.port !== CANONICAL_PORT); +/** + * Pure selection: given the discovered canonical port and the set of listening + * postmasters, return ALL legacy ones to stop (every port that is NOT + * canonical). A null canonical port means "cannot identify canonical" → never + * select anything. Returning every match (not just the first) ensures `apply` + * stops all strays in one pass; otherwise `validate` fails after 5s because + * the un-stopped siblings are still listening. Extracted for unit testing. + */ +export function selectLegacyEmbedded(canonicalPort: number | null, listening: ListeningPg[]): ListeningPg[] { + if (canonicalPort === null) return []; + return listening.filter((p) => p.port !== canonicalPort); +} + +function findLegacyEmbedded(): ListeningPg[] { + if (process.env.GENIE_KEEP_LEGACY_PG === '1') return []; + const canonicalPort = resolveCanonicalPort(); + if (canonicalPort === null) return []; + if (!canonicalReachable(canonicalPort)) return []; + return selectLegacyEmbedded(canonicalPort, listListeningPgserve()); } export async function check(_ctx: MigrationContext): Promise { - return findLegacyEmbedded() !== undefined; + return findLegacyEmbedded().length > 0; } -export async function apply(ctx: MigrationContext): Promise { - const target = findLegacyEmbedded(); - if (!target) { - ctx.log('no legacy embedded found at apply time (race resolved)'); - return; - } +function stopLegacy(ctx: MigrationContext, target: ListeningPg): void { ctx.log(`stopping legacy embedded pgserve PID ${target.pid} (port ${target.port})`); // Try pg_ctl stop via discovered data dir from the process try { @@ -88,7 +148,7 @@ export async function apply(ctx: MigrationContext): Promise { return; } } catch (err) { - ctx.warn(`pg_ctl stop failed: ${(err as Error).message} — falling back to SIGTERM`); + ctx.warn(`pg_ctl stop failed for PID ${target.pid}: ${(err as Error).message} — falling back to SIGTERM`); } // Fallback: SIGTERM the master process try { @@ -99,16 +159,28 @@ export async function apply(ctx: MigrationContext): Promise { } } +export async function apply(ctx: MigrationContext): Promise { + const targets = findLegacyEmbedded(); + if (targets.length === 0) { + ctx.log('no legacy embedded found at apply time (race resolved)'); + return; + } + for (const target of targets) { + stopLegacy(ctx, target); + } +} + export async function validate(_ctx: MigrationContext): Promise { - // Allow up to 5s for shutdown + // Allow up to 5s for all strays to shut down const deadline = Date.now() + 5000; while (Date.now() < deadline) { - if (findLegacyEmbedded() === undefined) return; + if (findLegacyEmbedded().length === 0) return; // small sleep Bun.sleepSync(200); } const remaining = findLegacyEmbedded(); - if (remaining) { - throw new Error(`legacy embedded still listening on port ${remaining.port} (PID ${remaining.pid}) after 5s`); + if (remaining.length > 0) { + const desc = remaining.map((p) => `port ${p.port} (PID ${p.pid})`).join(', '); + throw new Error(`legacy embedded still listening after 5s: ${desc}`); } } diff --git a/src/services/executors/claude-code.test.ts b/src/services/executors/claude-code.test.ts index 278d75b31..631d20a52 100644 --- a/src/services/executors/claude-code.test.ts +++ b/src/services/executors/claude-code.test.ts @@ -186,11 +186,12 @@ describe('buildOmniSpawnParams', () => { }); test('emits resume (not sessionId) when resumeClaudeSessionId is set', () => { - // Operator-facing invariant: omni-spawned chats are permanent. When the - // bridge respawns a per-chat agent and we have a prior Claude session id - // for that (agent, chat), buildOmniSpawnParams must surface it as `resume` - // so buildLaunchCommand emits `--resume ` and Claude reattaches to - // the same JSONL transcript. + // Operator-facing invariant: omni-bridged chats are permanent. When the + // bridge respawns a per-chat agent and we have a prior Claude session id, + // buildOmniSpawnParams must surface it as `resume` so buildLaunchCommand + // emits `--resume ` and Claude reattaches to the existing JSONL. + // If the JSONL is missing, launchOmniProcessInPane detects the silent + // failure and falls back to a fresh --session-id automatically. const priorClaudeSessionId = 'b25fb825-3695-4aa1-bcdf-7a4c20dc66c8'; const params = buildOmniSpawnParams( 'simone', @@ -201,18 +202,13 @@ describe('buildOmniSpawnParams', () => { priorClaudeSessionId, ); expect(params.resume).toBe(priorClaudeSessionId); - // sessionId must be omitted on resume — they are mutually exclusive in - // buildLaunchCommand. A leftover sessionId would cause a noisy fallback - // when `resume` is consumed first. expect(params.sessionId).toBeUndefined(); }); - test('falls back to fresh sessionId when resumeClaudeSessionId is undefined', () => { - // Backward compat: existing callers (and the first-ever spawn for a chat) - // pass no resume id and must still get a generated sessionId for - // `--session-id `. The newly-generated id is what gets - // persisted to executors.claude_session_id so the *next* respawn can - // resume it. + test('emits sessionId (not resume) when resumeClaudeSessionId is undefined', () => { + // First-ever spawn for a chat: no prior session, so we mint a fresh UUID + // for --session-id. This id is persisted to executors.claude_session_id + // so the next respawn can resume it via --resume. const params = buildOmniSpawnParams('simone', 'chat123', fakeEntry, {}, 'first message'); expect(params.resume).toBeUndefined(); expect(params.sessionId).toBeDefined(); diff --git a/src/services/executors/claude-code.ts b/src/services/executors/claude-code.ts index c32a3c425..a55d3af64 100644 --- a/src/services/executors/claude-code.ts +++ b/src/services/executors/claude-code.ts @@ -18,6 +18,7 @@ import { signOmniRequest } from '../../lib/omni-signature.js'; import { buildLaunchCommand } from '../../lib/provider-adapters.js'; import type { SpawnParams } from '../../lib/provider-adapters.js'; import { shellQuote } from '../../lib/team-lead-command.js'; +import { writeTmuxLaunchScript } from '../../lib/tmux-launch-script.js'; import { capturePaneContent, ensureTeamWindow, @@ -152,13 +153,11 @@ async function lookupChatName(chatId: string, _instanceId: string): Promise` instead of - * `--session-id `. Used by the omni bridge to attach a freshly- - * spawned tmux pane to the same Claude conversation that handled this chat - * before a crash/restart, so per-chat history persists across executor death. - * The two flags are mutually exclusive in `buildLaunchCommand` (see - * provider-adapters.ts) — `resume` wins when both are set, but we omit - * `sessionId` when resuming for clarity. + * @param resumeClaudeSessionId — when set, emits `--resume ` so the omni + * bridge reattaches to the same Claude conversation that handled this chat + * before a crash/restart. If the JSONL is missing (cleanup, fresh machine), + * `--resume` will silently fail; `launchOmniProcessInPane` detects that and + * falls back to a fresh `--session-id ` automatically. */ export function buildOmniSpawnParams( agentName: string, @@ -245,18 +244,42 @@ export class ClaudeCodeOmniExecutor implements IExecutor { resumeClaudeSessionId: string | undefined, ): Promise { const omniEnv: Record = { ...env, GENIE_OMNI_CHAT_ID: chatId, GENIE_OMNI_AGENT: agentName }; + + const sendToPane = async (params: SpawnParams): Promise => { + const launch = buildLaunchCommand(params); + const allEnv = { ...omniEnv, ...launch.env }; + const envPrefix = Object.entries(allEnv) + .map(([k, v]) => `${k}=${shellQuote(v)}`) + .join(' '); + const cmd = envPrefix ? `${envPrefix} ${launch.command}` : launch.command; + const scriptPath = writeTmuxLaunchScript(`omni-${chatId}`, cmd); + await executeTmux(`send-keys -t '${paneId}' "source ${scriptPath}" Enter`); + }; + const params = buildOmniSpawnParams(agentName, chatId, entry, omniEnv, initialMessage, resumeClaudeSessionId); - const claudeSessionId = resumeClaudeSessionId ?? params.sessionId ?? undefined; - const launch = buildLaunchCommand(params); - - // Merge omni-specific env vars with those produced by buildLaunchCommand. - const allEnv = { ...omniEnv, ...launch.env }; - const envPrefix = Object.entries(allEnv) - .map(([k, v]) => `${k}=${shellQuote(v)}`) - .join(' '); - const cmd = envPrefix ? `${envPrefix} ${launch.command}` : launch.command; - await executeTmux(`send-keys -t '${paneId}' ${shellQuote(cmd)} Enter`); - return claudeSessionId; + await sendToPane(params); + + if (resumeClaudeSessionId) { + // --resume silently fails when the JSONL is missing (e.g. cleanup, fresh + // machine): Claude exits immediately and the pane returns to the shell + // without printing an error. Detect this by polling for the process after + // a short settle window and, on failure, fall back to a fresh session so + // the inbound message is not lost. + await new Promise((r) => setTimeout(r, 3000)); + const processName = resolveOmniPaneProcessName(entry.provider); + const resumed = await isPaneProcessRunning(paneId, processName); + if (!resumed) { + console.warn( + `[claude-code] --resume ${resumeClaudeSessionId} failed for chat ${chatId} — JSONL likely missing. Falling back to fresh session.`, + ); + const freshParams = buildOmniSpawnParams(agentName, chatId, entry, omniEnv, initialMessage); + await sendToPane(freshParams); + return freshParams.sessionId; + } + return resumeClaudeSessionId; + } + + return params.sessionId; } async spawn( diff --git a/src/term-commands/agent/observe.ts b/src/term-commands/agent/observe.ts index cea2db24c..3995cc3e0 100644 --- a/src/term-commands/agent/observe.ts +++ b/src/term-commands/agent/observe.ts @@ -35,6 +35,7 @@ interface ObserveOptions { } const FLAG_LABELS: Record = { + agent_error: 'agent/executor lifecycle is error', stale_executor: 'stale executor (no recent heartbeat)', missing_session: 'missing session linkage', missing_attribution: 'missing attribution (no name/role)', diff --git a/src/term-commands/agents.ts b/src/term-commands/agents.ts index 8e93edae2..aab819f8b 100644 --- a/src/term-commands/agents.ts +++ b/src/term-commands/agents.ts @@ -45,6 +45,7 @@ import { resolveTeamForSpawn, resolveTeamForSpawnWithDeps, } from '../lib/team-resolver.js'; +import { writeTmuxLaunchScript } from '../lib/tmux-launch-script.js'; import { genieTmuxCmd, prependEnvVars } from '../lib/tmux-wrapper.js'; import * as tmux from '../lib/tmux.js'; import { TmuxUnreachableError, executeTmux, isPaneAlive } from '../lib/tmux.js'; @@ -797,28 +798,6 @@ function shellQuote(arg: string): string { return `'${arg.replace(/'/g, "'\\''")}'`; } -/** - * Write a temporary launch script for complex tmux spawns. - * - * Native Claude team launches carry many quoted flags and prompt-file args. In - * some shells that last outer-shell → tmux → inner-shell hop can mangle argv. - * Executing a script path removes one parsing layer and keeps the worker launch - * stable. - */ -function writeTmuxLaunchScript(workerId: string, fullCommand: string): string { - const { chmodSync, mkdirSync, writeFileSync } = require('node:fs'); - const { join } = require('node:path'); - const { homedir } = require('node:os'); - - const dir = join(homedir(), '.genie', 'spawn-scripts'); - mkdirSync(dir, { recursive: true }); - const safeId = workerId.replace(/[^a-zA-Z0-9._-]/g, '-'); - const scriptPath = join(dir, `${safeId}-${Date.now().toString(36)}.sh`); - writeFileSync(scriptPath, `#!/bin/sh\nexec ${fullCommand}\n`, { mode: 0o700 }); - chmodSync(scriptPath, 0o700); - return scriptPath; -} - /** * Build the split-window command for the first agent in a newly created team window. * diff --git a/src/term-commands/observe.ts b/src/term-commands/observe.ts index d28af01e1..4123e5d85 100644 --- a/src/term-commands/observe.ts +++ b/src/term-commands/observe.ts @@ -30,6 +30,7 @@ interface ObserveAgentsOptions { } const FLAG_LABELS: Record = { + agent_error: 'ERROR', stale_executor: 'STALE', missing_session: 'NO-SESSION', missing_attribution: 'UNATTRIBUTED', diff --git a/src/term-commands/serve.test.ts b/src/term-commands/serve.test.ts index f55105d1c..c2f7f28f8 100644 --- a/src/term-commands/serve.test.ts +++ b/src/term-commands/serve.test.ts @@ -76,6 +76,7 @@ const { getTuiKeybindings, getTuiQuitBindingArgs, isStoppingLockActive, + printStandaloneBrainStatus, startBrainServerIfEnabled, startTuiTmuxServer, writeStoppingLockSync, @@ -245,6 +246,19 @@ describe('brain startup integration', () => { } }); + test('reports a standalone brain binary as installed even when no server is running', async () => { + const logs: string[] = []; + + const handled = await printStandaloneBrainStatus({ + readActiveConfig: () => null, + getStandaloneVersion: () => '1.64.0', + log: (message: string) => logs.push(message), + }); + + expect(handled).toBe(true); + expect(logs).toEqual([' brain: installed standalone (1.64.0, server not running)']); + }); + test('schedules a later brain vault while an earlier start is pending', async () => { const root = join(tmpdir(), `genie-serve-brain-parallel-${Date.now()}-${Math.random().toString(36).slice(2)}`); const first = makeVault(root, 'first'); diff --git a/src/term-commands/serve.ts b/src/term-commands/serve.ts index 5cb818e3c..f69061469 100644 --- a/src/term-commands/serve.ts +++ b/src/term-commands/serve.ts @@ -1577,20 +1577,45 @@ function readActiveBrainConfigFile(): ActiveBrainConfigFile | null { } } -async function printStandaloneBrainStatus(): Promise { - const config = readActiveBrainConfigFile(); - if (!config?.port) return false; +function getStandaloneBrainVersion(): string | null { try { - const resp = await fetch(`http://127.0.0.1:${config.port}/healthz`); + const result = spawnSync('brain', ['--version'], { encoding: 'utf-8' }); + if (result.status !== 0) return null; + const version = (result.stdout || result.stderr || '').trim().split(/\s+/)[0]; + return version || 'unknown version'; + } catch { + return null; + } +} + +interface StandaloneBrainStatusDeps { + readActiveConfig?: () => ActiveBrainConfigFile | null; + getStandaloneVersion?: () => string | null; + fetchImpl?: typeof fetch; + log?: (message: string) => void; +} + +export async function printStandaloneBrainStatus(deps: StandaloneBrainStatusDeps = {}): Promise { + const log = deps.log ?? console.log; + const config = (deps.readActiveConfig ?? readActiveBrainConfigFile)(); + if (!config?.port) { + const version = (deps.getStandaloneVersion ?? getStandaloneBrainVersion)(); + if (!version) return false; + log(` brain: installed standalone (${version}, server not running)`); + return true; + } + try { + const fetchHealth = deps.fetchImpl ?? fetch; + const resp = await fetchHealth(`http://127.0.0.1:${config.port}/healthz`); const label = config.brainSlug ?? config.database ?? `port ${config.port}`; - console.log( + log( resp.ok ? ` brain: running (${label}, port ${config.port})` : ` brain: unhealthy (${label}, status ${resp.status})`, ); return true; } catch { - console.log(` brain: stopped (port ${config.port} unreachable)`); + log(` brain: stopped (port ${config.port} unreachable)`); return true; } } diff --git a/test/migrations/002-kill-embedded-pgserve-legacy.test.ts b/test/migrations/002-kill-embedded-pgserve-legacy.test.ts new file mode 100644 index 000000000..8775dcf1a --- /dev/null +++ b/test/migrations/002-kill-embedded-pgserve-legacy.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from 'bun:test'; + +import { selectLegacyEmbedded } from '../../src/migrations/steps/002-kill-embedded-pgserve-legacy.js'; + +// Regression guard for the canonical-port-discovery fix: the legacy selector +// must compare against the DISCOVERED canonical port, never a hardcoded 8432, +// and must return EVERY stray (not just the first) so apply stops them all. + +test('never selects the canonical postmaster (5432-only healthy host)', () => { + expect(selectLegacyEmbedded(5432, [{ pid: 100, port: 5432 }])).toEqual([]); +}); + +test('selects the stray 8432, NOT the canonical 5432 (mixed migration window)', () => { + expect( + selectLegacyEmbedded(5432, [ + { pid: 100, port: 5432 }, + { pid: 200, port: 8432 }, + ]), + ).toEqual([{ pid: 200, port: 8432 }]); +}); + +test('selects ALL strays, not just the first (multiple legacy postmasters)', () => { + expect( + selectLegacyEmbedded(5432, [ + { pid: 100, port: 5432 }, + { pid: 200, port: 8432 }, + { pid: 300, port: 9432 }, + ]), + ).toEqual([ + { pid: 200, port: 8432 }, + { pid: 300, port: 9432 }, + ]); +}); + +test('legacy-era host: canonical on 8432, nothing stray → no-op', () => { + expect(selectLegacyEmbedded(8432, [{ pid: 100, port: 8432 }])).toEqual([]); +}); + +test('canonical port unidentifiable (null) → never selects anything', () => { + expect(selectLegacyEmbedded(null, [{ pid: 100, port: 5432 }])).toEqual([]); + expect(selectLegacyEmbedded(null, [])).toEqual([]); +});