bosun 0.41.8 → 0.41.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.env.example +1 -1
  2. package/README.md +23 -1
  3. package/agent/agent-event-bus.mjs +31 -2
  4. package/agent/agent-pool.mjs +275 -40
  5. package/agent/agent-prompts.mjs +9 -1
  6. package/agent/agent-supervisor.mjs +22 -0
  7. package/agent/autofix.mjs +1 -1
  8. package/agent/primary-agent.mjs +115 -5
  9. package/cli.mjs +3 -2
  10. package/config/config.mjs +47 -33
  11. package/config/context-shredding-config.mjs +1 -1
  12. package/config/repo-root.mjs +41 -33
  13. package/desktop/main.mjs +350 -25
  14. package/desktop/preload.cjs +8 -0
  15. package/desktop/preload.mjs +19 -0
  16. package/entrypoint.mjs +332 -0
  17. package/git/sdk-conflict-resolver.mjs +1 -1
  18. package/infra/health-status.mjs +72 -0
  19. package/infra/library-manager.mjs +58 -1
  20. package/infra/maintenance.mjs +1 -2
  21. package/infra/monitor.mjs +26 -8
  22. package/infra/session-tracker.mjs +30 -3
  23. package/package.json +12 -4
  24. package/server/bosun-mcp-server.mjs +1004 -0
  25. package/server/setup-web-server.mjs +288 -259
  26. package/server/ui-server.mjs +1323 -26
  27. package/shell/claude-shell.mjs +14 -1
  28. package/shell/codex-config.mjs +1 -1
  29. package/shell/codex-model-profiles.mjs +170 -30
  30. package/shell/codex-shell.mjs +63 -18
  31. package/shell/opencode-providers.mjs +20 -8
  32. package/task/task-executor.mjs +28 -0
  33. package/task/task-store.mjs +13 -4
  34. package/telegram/telegram-sentinel.mjs +54 -3
  35. package/tools/list-todos.mjs +7 -1
  36. package/ui/app.js +3 -2
  37. package/ui/components/agent-selector.js +127 -0
  38. package/ui/components/session-list.js +15 -10
  39. package/ui/demo-defaults.js +334 -336
  40. package/ui/modules/router.js +2 -0
  41. package/ui/modules/state.js +13 -5
  42. package/ui/tabs/chat.js +3 -0
  43. package/ui/tabs/library.js +284 -52
  44. package/ui/tabs/tasks.js +5 -13
  45. package/ui/tabs/workflows.js +766 -3
  46. package/workflow/workflow-engine.mjs +246 -5
  47. package/workflow/workflow-nodes/definitions.mjs +37 -0
  48. package/workflow/workflow-nodes.mjs +1014 -184
  49. package/workflow/workflow-templates.mjs +0 -5
  50. package/workflow-templates/_helpers.mjs +253 -0
  51. package/workflow-templates/agents.mjs +199 -226
  52. package/workflow-templates/github.mjs +106 -16
  53. package/workflow-templates/sub-workflows.mjs +233 -0
  54. package/workflow-templates/task-execution.mjs +125 -471
  55. package/workflow-templates/task-lifecycle.mjs +11 -48
  56. package/workspace/command-diagnostics.mjs +460 -0
  57. package/workspace/context-cache.mjs +396 -28
  58. package/workspace/worktree-manager.mjs +1 -1
@@ -35,7 +35,7 @@
35
35
  * NO → release-slot → log skipped
36
36
  */
37
37
 
38
- import { node, edge, resetLayout } from "./_helpers.mjs";
38
+ import { node, edge, resetLayout, agentPhase } from "./_helpers.mjs";
39
39
 
40
40
  // ═══════════════════════════════════════════════════════════════════════════
41
41
  // Task Lifecycle — Full Task Execution Pipeline
@@ -175,46 +175,19 @@ export const TASK_LIFECYCLE_TEMPLATE = {
175
175
  repositories: "{{repositories}}",
176
176
  }, { x: 200, y: 1610 }),
177
177
  // ── Execute agent (phase 1: planning) ───────────────────────────────
178
- node("run-agent-plan", "action.run_agent", "Agent Plan", {
179
- prompt: "{{_taskPrompt}}\n\nExecution phase: planning. Produce a concrete implementation plan and identify required tests. Do not make code changes in this phase.",
180
- taskId: "{{taskId}}",
181
- sdk: "{{resolvedSdk}}",
182
- model: "{{resolvedModel}}",
183
- agentProfile: "{{agentProfile}}",
184
- cwd: "{{worktreePath}}",
185
- timeoutMs: "{{taskTimeoutMs}}",
186
- maxRetries: "{{maxRetries}}",
187
- maxContinues: "{{maxContinues}}",
188
- failOnError: false,
189
- }, { x: 200, y: 1740 }),
178
+ agentPhase("run-agent-plan", "Agent Plan",
179
+ "{{_taskPrompt}}\n\nExecution phase: planning. Produce a concrete implementation plan and identify required tests. Do not make code changes in this phase.",
180
+ {}, { x: 200, y: 1740 }),
190
181
 
191
182
  // ── Execute agent (phase 2: tests-first) ────────────────────────────
192
- node("run-agent-tests", "action.run_agent", "Agent Tests", {
193
- prompt: "{{_taskPrompt}}\n\nExecution phase: tests. Write or update tests first for the target behavior, then validate failures/pass criteria before implementation changes.",
194
- taskId: "{{taskId}}",
195
- sdk: "{{resolvedSdk}}",
196
- model: "{{resolvedModel}}",
197
- agentProfile: "{{agentProfile}}",
198
- cwd: "{{worktreePath}}",
199
- timeoutMs: "{{taskTimeoutMs}}",
200
- maxRetries: "{{maxRetries}}",
201
- maxContinues: "{{maxContinues}}",
202
- failOnError: false,
203
- }, { x: 200, y: 1545 }),
183
+ agentPhase("run-agent-tests", "Agent Tests",
184
+ "{{_taskPrompt}}\n\nExecution phase: tests. Write or update tests first for the target behavior, then validate failures/pass criteria before implementation changes.",
185
+ {}, { x: 200, y: 1545 }),
204
186
 
205
187
  // ── Execute agent (phase 3: implementation + verification) ──────────
206
- node("run-agent-implement", "action.run_agent", "Agent Implement", {
207
- prompt: "{{_taskPrompt}}\n\nExecution phase: implementation. Complete implementation after tests exist, run required verification (tests/lint/build), then commit, push, and create/update PR.",
208
- taskId: "{{taskId}}",
209
- sdk: "{{resolvedSdk}}",
210
- model: "{{resolvedModel}}",
211
- agentProfile: "{{agentProfile}}",
212
- cwd: "{{worktreePath}}",
213
- timeoutMs: "{{taskTimeoutMs}}",
214
- maxRetries: "{{maxRetries}}",
215
- maxContinues: "{{maxContinues}}",
216
- failOnError: false,
217
- }, { x: 200, y: 1610 }),
188
+ agentPhase("run-agent-implement", "Agent Implement",
189
+ "{{_taskPrompt}}\n\nExecution phase: implementation. Complete implementation after tests exist, run required verification (tests/lint/build), then commit, push, and create/update PR.",
190
+ {}, { x: 200, y: 1610 }),
218
191
 
219
192
  // ── Check if claim was stolen during agent execution ─────────────────
220
193
  node("claim-stolen", "condition.expression", "Claim Stolen?", {
@@ -681,17 +654,7 @@ export const VE_ORCHESTRATOR_LITE_TEMPLATE = {
681
654
  }, { x: 300, y: 1480 }),
682
655
 
683
656
  // ── Run agent ────────────────────────────────────────────────────────
684
- node("agent", "action.run_agent", "Run Agent", {
685
- prompt: "{{_taskPrompt}}",
686
- taskId: "{{taskId}}",
687
- sdk: "{{resolvedSdk}}",
688
- model: "{{resolvedModel}}",
689
- agentProfile: "{{agentProfile}}",
690
- cwd: "{{worktreePath}}",
691
- timeoutMs: "{{taskTimeoutMs}}",
692
- maxRetries: "{{maxRetries}}",
693
- failOnError: false,
694
- }, { x: 300, y: 1610 }),
657
+ agentPhase("agent", "Run Agent", "{{_taskPrompt}}", {}, { x: 300, y: 1610 }),
695
658
 
696
659
  // ── Detect commits ───────────────────────────────────────────────────
697
660
  node("commits", "action.detect_new_commits", "Check Commits", {
@@ -0,0 +1,460 @@
1
+ import { resolve, dirname } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ const __filename = fileURLToPath(import.meta.url);
5
+ const __dirname = dirname(__filename);
6
+
7
+ const STATE_FILE = process.env.BOSUN_COMMAND_DIAGNOSTICS_STATE_FILE
8
+ ? resolve(process.env.BOSUN_COMMAND_DIAGNOSTICS_STATE_FILE)
9
+ : resolve(__dirname, "..", ".cache", "command-diagnostics", "state.json");
10
+ const CACHE_DIR = dirname(STATE_FILE);
11
+ const MAX_STATE_RECORDS = 120;
12
+ const MAX_SCAN_CHARS = 20_000;
13
+ const MAX_SCAN_LINES = 400;
14
+
15
+ let _fsPromises = null;
16
+ let _stateCache = null;
17
+
18
+ async function getFs() {
19
+ if (!_fsPromises) {
20
+ _fsPromises = await import("node:fs/promises");
21
+ }
22
+ return _fsPromises;
23
+ }
24
+
25
+ async function ensureCacheDir() {
26
+ const fs = await getFs();
27
+ await fs.mkdir(CACHE_DIR, { recursive: true });
28
+ }
29
+
30
+ async function loadState() {
31
+ if (_stateCache) return _stateCache;
32
+ const fs = await getFs();
33
+ try {
34
+ const raw = await fs.readFile(STATE_FILE, "utf8");
35
+ const parsed = JSON.parse(raw);
36
+ _stateCache = parsed && typeof parsed === "object" ? parsed : { records: {} };
37
+ } catch {
38
+ _stateCache = { records: {} };
39
+ }
40
+ if (!_stateCache.records || typeof _stateCache.records !== "object") {
41
+ _stateCache.records = {};
42
+ }
43
+ return _stateCache;
44
+ }
45
+
46
+ async function saveState(state) {
47
+ const fs = await getFs();
48
+ await ensureCacheDir();
49
+ await fs.writeFile(STATE_FILE, JSON.stringify(state, null, 2), "utf8");
50
+ }
51
+
52
+ function collapseWhitespace(value) {
53
+ return String(value || "").replace(/\s+/g, " ").trim();
54
+ }
55
+
56
+ function uniqueValues(values = []) {
57
+ return [...new Set(values.filter(Boolean).map((value) => String(value).trim()).filter(Boolean))];
58
+ }
59
+
60
+ function quoteArg(value) {
61
+ const raw = String(value || "");
62
+ if (!raw) return '""';
63
+ if (!/[\\s"'|&()\\\\]/.test(raw)) return raw;
64
+ return `"${raw.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
65
+ }
66
+
67
+ function getDiagnosticSample(value = "") {
68
+ return String(value || "").slice(0, MAX_SCAN_CHARS);
69
+ }
70
+
71
+ function getDiagnosticLines(value = "") {
72
+ return getDiagnosticSample(value).split(/\r?\n/).slice(0, MAX_SCAN_LINES);
73
+ }
74
+
75
+ function trimTokenEdge(token = "") {
76
+ let start = 0;
77
+ let end = token.length;
78
+ while (start < end && "\"'`([{<".includes(token[start])) start += 1;
79
+ while (end > start && "\"'`)]}>,;.!?".includes(token[end - 1])) end -= 1;
80
+ return token.slice(start, end);
81
+ }
82
+
83
+ function isLikelyFileRef(token = "") {
84
+ if (!token || token.length < 3) return false;
85
+ if (token.includes("://")) return false;
86
+ const hasPathSeparator = token.includes("/") || token.includes("\\");
87
+ const hasDrivePrefix = /^[A-Za-z]:/.test(token);
88
+ const hasRelativePrefix = token.startsWith("./") || token.startsWith("../") || token.startsWith("~/");
89
+ if (!hasPathSeparator && !hasDrivePrefix && !hasRelativePrefix) return false;
90
+ return token.includes(".");
91
+ }
92
+
93
+ function extractLeadingInteger(value = "") {
94
+ let digits = "";
95
+ for (const char of String(value || "")) {
96
+ if (char < "0" || char > "9") break;
97
+ digits += char;
98
+ }
99
+ return digits ? Number(digits) : null;
100
+ }
101
+
102
+ function findSummaryCount(text = "", label = "failed") {
103
+ const lowerLabel = String(label || "").toLowerCase();
104
+ for (const line of getDiagnosticLines(text)) {
105
+ const tokens = line.trim().split(/\s+/).filter(Boolean);
106
+ for (let index = 0; index < tokens.length - 1; index += 1) {
107
+ if (tokens[index + 1].toLowerCase().startsWith(lowerLabel)) {
108
+ const numeric = extractLeadingInteger(tokens[index].replace(/^=/, ""));
109
+ if (numeric !== null) return numeric;
110
+ }
111
+ }
112
+ }
113
+ return null;
114
+ }
115
+
116
+ function normalizeCommandSignature(command = "", args = []) {
117
+ const commandLine = collapseWhitespace(
118
+ [String(command || "").trim(), ...(Array.isArray(args) ? args.map((value) => String(value || "").trim()) : [])]
119
+ .filter(Boolean)
120
+ .join(" "),
121
+ );
122
+ return commandLine.toLowerCase();
123
+ }
124
+
125
+ function resolveCommandKind(commandLine = "", output = "") {
126
+ const lower = `${commandLine}\n${output}`.toLowerCase();
127
+ if (/\bdotnet\s+test\b/.test(lower)) return { family: "test", runner: "dotnet-test" };
128
+ if (/\b(?:python(?:3)?\s+-m\s+pytest|pytest)\b/.test(lower)) return { family: "test", runner: "pytest" };
129
+ if (/\bvitest\b/.test(lower)) return { family: "test", runner: "vitest" };
130
+ if (/\bjest\b/.test(lower)) return { family: "test", runner: "jest" };
131
+ if (/\bgo\s+test\b/.test(lower)) return { family: "test", runner: "go-test" };
132
+ const outputLines = getDiagnosticLines(output);
133
+ const hasVitestFailureLine = outputLines.some((line) => {
134
+ const trimmed = line.trimStart().toLowerCase();
135
+ return trimmed.startsWith("fail ") && (trimmed.includes(".test.") || trimmed.includes(".spec."));
136
+ });
137
+ const hasVitestSummary = lower.includes("test files") && lower.includes("failed");
138
+ if (hasVitestFailureLine || hasVitestSummary) {
139
+ return { family: "test", runner: "vitest" };
140
+ }
141
+ const hasPytestFailureLine = outputLines.some((line) => {
142
+ const trimmed = line.trimStart();
143
+ return trimmed.startsWith("FAILED ") && trimmed.includes("::") && trimmed.includes(" - ");
144
+ });
145
+ const hasPytestCollection = lower.includes("collected ") && lower.includes(" items");
146
+ if (hasPytestFailureLine || hasPytestCollection) {
147
+ return { family: "test", runner: "pytest" };
148
+ }
149
+ if (/\bgit\s+diff\b/.test(lower)) return { family: "git", runner: "git-diff" };
150
+ if (/\bgit\s+status\b/.test(lower)) return { family: "git", runner: "git-status" };
151
+ if (/\bgit\s+(show|log|grep|rebase|merge|pull|push)\b/.test(lower)) return { family: "git", runner: "git" };
152
+ if (/\b(test|build|compile|lint|typecheck|msbuild|tsc|cargo|mvn|gradle)\b/.test(lower)) return { family: "build", runner: "build" };
153
+ return { family: "generic", runner: "generic" };
154
+ }
155
+
156
+ function countRegex(text, regex) {
157
+ const matches = String(text || "").match(regex);
158
+ return Array.isArray(matches) ? matches.length : 0;
159
+ }
160
+
161
+ function extractFileRefs(text = "", limit = 10) {
162
+ const refs = [];
163
+ for (const line of getDiagnosticLines(text)) {
164
+ for (const token of line.split(/\s+/)) {
165
+ const trimmed = trimTokenEdge(token);
166
+ if (!isLikelyFileRef(trimmed)) continue;
167
+ refs.push(trimmed);
168
+ if (refs.length >= limit) {
169
+ return uniqueValues(refs);
170
+ }
171
+ }
172
+ }
173
+ return uniqueValues(refs);
174
+ }
175
+
176
+ function parseDotnetTest(text) {
177
+ const failedTargets = [];
178
+ for (const match of String(text).matchAll(/^Failed\s+([A-Za-z0-9_.`]+)\s+\[[^\]]+\]/gm)) {
179
+ failedTargets.push(match[1]);
180
+ }
181
+ const summaryMatch = text.match(/Failed!\s*-\s*Failed:\s*(\d+),\s*Passed:\s*(\d+),\s*Skipped:\s*(\d+),\s*Total:\s*(\d+)/i);
182
+ const summary = summaryMatch
183
+ ? `${summaryMatch[1]} failed, ${summaryMatch[2]} passed, ${summaryMatch[3]} skipped (${summaryMatch[4]} total)`
184
+ : failedTargets.length
185
+ ? `${failedTargets.length} failing .NET test${failedTargets.length === 1 ? "" : "s"}`
186
+ : "";
187
+ return {
188
+ failedTargets: uniqueValues(failedTargets),
189
+ summary,
190
+ rerunCommand: failedTargets.length
191
+ ? `dotnet test --filter ${quoteArg(failedTargets.map((target) => `FullyQualifiedName~${target}`).join("|"))}`
192
+ : null,
193
+ };
194
+ }
195
+
196
+ function parsePytest(text) {
197
+ const failedTargets = [];
198
+ for (const line of getDiagnosticLines(text)) {
199
+ const trimmed = line.trimStart();
200
+ if (!trimmed.startsWith("FAILED ")) continue;
201
+ const payload = trimmed.slice("FAILED ".length).trim();
202
+ const separatorIndex = payload.indexOf(" - ");
203
+ failedTargets.push((separatorIndex === -1 ? payload : payload.slice(0, separatorIndex)).trim());
204
+ }
205
+ const failedCount = findSummaryCount(text, "failed") ?? failedTargets.length;
206
+ const summary = failedCount ? `${failedCount} failed pytest target${failedCount === 1 ? "" : "s"}` : "";
207
+ return {
208
+ failedTargets: uniqueValues(failedTargets),
209
+ summary,
210
+ rerunCommand: failedTargets.length
211
+ ? `pytest ${failedTargets.slice(0, 8).map(quoteArg).join(" ")}`
212
+ : null,
213
+ };
214
+ }
215
+
216
+ function parseVitestLike(text, runner = "vitest") {
217
+ const failedTargets = [];
218
+ for (const line of getDiagnosticLines(text)) {
219
+ const trimmed = line.trimStart();
220
+ if (!trimmed.startsWith("FAIL ")) continue;
221
+ failedTargets.push(trimmed.slice("FAIL ".length).trim());
222
+ }
223
+ const fileRefs = extractFileRefs(text, 12).filter((value) => /\.(test|spec)\.[A-Za-z0-9]+(?::\d+)?$/i.test(value));
224
+ const effectiveTargets = uniqueValues([...failedTargets, ...fileRefs]);
225
+ const summaryMatch =
226
+ text.match(/Test Files\s+(\d+)\s+failed(?:\s*\|\s*(\d+)\s+passed)?/i) ||
227
+ text.match(/Tests?\s*:\s*(\d+)\s+failed/i);
228
+ const summary = summaryMatch
229
+ ? summaryMatch[2]
230
+ ? `${summaryMatch[1]} failed file${summaryMatch[1] === "1" ? "" : "s"}, ${summaryMatch[2]} passed`
231
+ : `${summaryMatch[1]} failed test target${summaryMatch[1] === "1" ? "" : "s"}`
232
+ : effectiveTargets.length
233
+ ? `${effectiveTargets.length} failing ${runner} target${effectiveTargets.length === 1 ? "" : "s"}`
234
+ : "";
235
+ return {
236
+ failedTargets: effectiveTargets,
237
+ summary,
238
+ rerunCommand: effectiveTargets.length
239
+ ? `${runner === "jest" ? "jest" : "vitest run"} ${effectiveTargets.slice(0, 8).map(quoteArg).join(" ")}`
240
+ : null,
241
+ };
242
+ }
243
+
244
+ function parseGoTest(text) {
245
+ const failedTests = [];
246
+ for (const match of String(text).matchAll(/^--- FAIL:\s+([^\s(]+)/gm)) {
247
+ failedTests.push(match[1]);
248
+ }
249
+ const failedPackages = [];
250
+ for (const match of String(text).matchAll(/^FAIL\t([^\s]+)\t/gm)) {
251
+ failedPackages.push(match[1]);
252
+ }
253
+ const summary = failedTests.length
254
+ ? `${failedTests.length} failing Go test${failedTests.length === 1 ? "" : "s"}`
255
+ : failedPackages.length
256
+ ? `${failedPackages.length} failing Go package${failedPackages.length === 1 ? "" : "s"}`
257
+ : "";
258
+ let rerunCommand = null;
259
+ if (failedTests.length) {
260
+ rerunCommand = `go test ./... -run ${quoteArg(`^(${uniqueValues(failedTests).slice(0, 8).join("|")})$`)}`;
261
+ } else if (failedPackages.length) {
262
+ rerunCommand = `go test ${uniqueValues(failedPackages).slice(0, 8).map(quoteArg).join(" ")}`;
263
+ }
264
+ return {
265
+ failedTargets: uniqueValues([...failedTests, ...failedPackages]),
266
+ summary,
267
+ rerunCommand,
268
+ };
269
+ }
270
+
271
+ function parseGitOutput(text, runner, commandLine) {
272
+ const fileRefs = extractFileRefs(text, 12);
273
+ const diffFiles = countRegex(text, /^diff --git /gm);
274
+ const changedEntries = countRegex(text, /^(M|A|D|R|\?\?)\s+/gm);
275
+ const summary = runner === "git-diff"
276
+ ? diffFiles
277
+ ? `${diffFiles} diff file${diffFiles === 1 ? "" : "s"} in output`
278
+ : fileRefs.length
279
+ ? `${fileRefs.length} referenced file${fileRefs.length === 1 ? "" : "s"} in diff output`
280
+ : ""
281
+ : runner === "git-status"
282
+ ? changedEntries
283
+ ? `${changedEntries} changed path${changedEntries === 1 ? "" : "s"} in status output`
284
+ : ""
285
+ : fileRefs.length
286
+ ? `${fileRefs.length} referenced file${fileRefs.length === 1 ? "" : "s"} in git output`
287
+ : "";
288
+ let rerunCommand = null;
289
+ if (runner === "git-diff" && !/\s--stat\b/.test(commandLine)) {
290
+ rerunCommand = "git diff --stat";
291
+ } else if (runner === "git-status" && !/\s--short\b/.test(commandLine)) {
292
+ rerunCommand = "git status --short";
293
+ }
294
+ return {
295
+ failedTargets: fileRefs,
296
+ summary,
297
+ rerunCommand,
298
+ };
299
+ }
300
+
301
+ function parseGeneric(text) {
302
+ const fileRefs = extractFileRefs(text, 12);
303
+ const errorCount = countRegex(text, /\b(error|failed|fatal|panic|traceback|exception)\b/gi);
304
+ return {
305
+ failedTargets: fileRefs,
306
+ summary: errorCount
307
+ ? `${errorCount} error signal${errorCount === 1 ? "" : "s"} detected`
308
+ : fileRefs.length
309
+ ? `${fileRefs.length} file anchor${fileRefs.length === 1 ? "" : "s"} detected`
310
+ : "",
311
+ rerunCommand: null,
312
+ };
313
+ }
314
+
315
+ function buildDelta(previousTargets = [], currentTargets = []) {
316
+ const prevSet = new Set(previousTargets);
317
+ const currSet = new Set(currentTargets);
318
+ const resolved = previousTargets.filter((value) => !currSet.has(value));
319
+ const remaining = currentTargets.filter((value) => prevSet.has(value));
320
+ const introduced = currentTargets.filter((value) => !prevSet.has(value));
321
+ return {
322
+ resolved: uniqueValues(resolved),
323
+ remaining: uniqueValues(remaining),
324
+ introduced: uniqueValues(introduced),
325
+ };
326
+ }
327
+
328
+ function deriveHint({ family, runner, text, exitCode, insufficientSignal }) {
329
+ const normalized = String(text || "").toLowerCase();
330
+ if (insufficientSignal) {
331
+ return "Signal coverage is low. Retrieve the full log or rerun with a narrower test/build command.";
332
+ }
333
+ if (/econnrefused|connection refused|service unavailable|timed out|timeout/.test(normalized)) {
334
+ return "Check dependent services or network reachability before rerunning.";
335
+ }
336
+ if (/permission denied|eacces|access is denied/.test(normalized)) {
337
+ return "Fix permissions or sandbox access before rerunning.";
338
+ }
339
+ if (/fixture|setup failed|conftest|beforeall|collection failed/.test(normalized)) {
340
+ return "Fix shared setup or fixture failures before rerunning the whole suite.";
341
+ }
342
+ if (family === "git" && exitCode !== 0) {
343
+ return "Narrow the git view first, then inspect the full log if the failure is still unclear.";
344
+ }
345
+ if (runner === "dotnet-test" && /cs\d+|msb\d+|nu\d+/i.test(text)) {
346
+ return "Resolve the reported build or package diagnostics before rerunning tests.";
347
+ }
348
+ return "";
349
+ }
350
+
351
+ function pruneRecords(records) {
352
+ const entries = Object.entries(records || {}).sort((left, right) =>
353
+ String(right[1]?.updatedAt || "").localeCompare(String(left[1]?.updatedAt || "")),
354
+ );
355
+ return Object.fromEntries(entries.slice(0, MAX_STATE_RECORDS));
356
+ }
357
+
358
+ export function renderCommandDiagnosticFooter(diagnostic = null) {
359
+ if (!diagnostic || typeof diagnostic !== "object") return "";
360
+ const lines = [];
361
+ if (diagnostic.summary) lines.push(`Summary: ${diagnostic.summary}`);
362
+ if (diagnostic.deltaSummary) lines.push(`Delta: ${diagnostic.deltaSummary}`);
363
+ if (diagnostic.suggestedRerun) lines.push(`Suggested rerun: ${diagnostic.suggestedRerun}`);
364
+ if (diagnostic.hint) lines.push(`Hint: ${diagnostic.hint}`);
365
+ if (diagnostic.insufficientSignal) lines.push("Signal coverage: low");
366
+ return lines.length ? `Diagnostics:\n${lines.join("\n")}` : "";
367
+ }
368
+
369
+ export async function analyzeCommandDiagnostic(payload = {}) {
370
+ const command = String(payload.command || "").trim();
371
+ const args = Array.isArray(payload.args) ? payload.args.map((value) => String(value || "")) : [];
372
+ const output = String(payload.output || payload.stdout || "");
373
+ const stderr = String(payload.stderr || "");
374
+ const text = [output.trim(), stderr.trim() && stderr.trim() !== output.trim() ? `[stderr]\n${stderr.trim()}` : ""]
375
+ .filter(Boolean)
376
+ .join("\n\n")
377
+ .trim();
378
+ if (!command || !text) return null;
379
+
380
+ const commandLine = collapseWhitespace([command, ...args].join(" "));
381
+ const exitCode = Number.isFinite(Number(payload.exitCode)) ? Number(payload.exitCode) : 0;
382
+ const { family, runner } = resolveCommandKind(commandLine, text);
383
+
384
+ let parsed;
385
+ switch (runner) {
386
+ case "dotnet-test":
387
+ parsed = parseDotnetTest(text);
388
+ break;
389
+ case "pytest":
390
+ parsed = parsePytest(text);
391
+ break;
392
+ case "vitest":
393
+ case "jest":
394
+ parsed = parseVitestLike(text, runner);
395
+ break;
396
+ case "go-test":
397
+ parsed = parseGoTest(text);
398
+ break;
399
+ case "git":
400
+ case "git-diff":
401
+ case "git-status":
402
+ parsed = parseGitOutput(text, runner, commandLine);
403
+ break;
404
+ default:
405
+ parsed = parseGeneric(text);
406
+ break;
407
+ }
408
+
409
+ const failedTargets = uniqueValues(parsed.failedTargets || []);
410
+ const fileAnchors = extractFileRefs(text, 10);
411
+ const insufficientSignal =
412
+ exitCode !== 0 &&
413
+ failedTargets.length === 0 &&
414
+ fileAnchors.length === 0 &&
415
+ !parsed.summary &&
416
+ text.length >= 1200;
417
+
418
+ const state = await loadState();
419
+ const commandKey = normalizeCommandSignature(command, args);
420
+ const previous = state.records[commandKey] || null;
421
+ const delta = previous ? buildDelta(previous.failedTargets || [], failedTargets) : null;
422
+
423
+ const deltaParts = [];
424
+ if (delta) {
425
+ if (delta.resolved.length) deltaParts.push(`${delta.resolved.length} resolved`);
426
+ if (delta.remaining.length) deltaParts.push(`${delta.remaining.length} still failing`);
427
+ if (delta.introduced.length) deltaParts.push(`${delta.introduced.length} new`);
428
+ }
429
+ const deltaSummary = deltaParts.join(", ");
430
+ const suggestedRerun = parsed.rerunCommand || null;
431
+ const hint = deriveHint({ family, runner, text, exitCode, insufficientSignal });
432
+
433
+ state.records[commandKey] = {
434
+ updatedAt: new Date().toISOString(),
435
+ family,
436
+ runner,
437
+ commandLine,
438
+ exitCode,
439
+ failedTargets: failedTargets.slice(0, 40),
440
+ summary: parsed.summary || "",
441
+ };
442
+ state.records = pruneRecords(state.records);
443
+ await saveState(state);
444
+
445
+ return {
446
+ family,
447
+ runner,
448
+ commandKey,
449
+ summary: parsed.summary || "",
450
+ failedTargets,
451
+ fileAnchors,
452
+ insufficientSignal,
453
+ deltaSummary,
454
+ resolvedTargets: delta?.resolved || [],
455
+ remainingTargets: delta?.remaining || [],
456
+ newTargets: delta?.introduced || [],
457
+ suggestedRerun,
458
+ hint,
459
+ };
460
+ }