* feat: surface real doctor issue details in progress score widget
Previously the progress score traffic light (green/yellow/red) only
showed generic labels like "2 consecutive error units" or "Health
trend declining". The actual doctor issue descriptions were computed
in auto-post-unit but discarded before reaching the widget — only
aggregate counts were stored in HealthSnapshot.
Now the full data flows through:
- HealthSnapshot stores issue details (code, message, severity,
unitId) and fix descriptions alongside the counts
- recordHealthSnapshot() accepts optional issue/fix arrays
(backwards compatible — existing callers unchanged)
- getLatestHealthIssues() and getLatestHealthFixes() retrieve the
most recent details for display
- computeProgressScore() surfaces up to 5 real issue messages
(errors first) and up to 3 recent fixes as ProgressSignals
when the level is yellow or red
- Dashboard overlay renders signal details with ✓/✗/· icons
below the traffic light when degraded
This gives real-time visibility into what the auto-doctor is
detecting and fixing, without requiring manual /gsd doctor runs
or opening the full dashboard to investigate.
* feat: integrate doctor health data into visualizer and HTML reports
Phase 2b: close visibility gaps across visualizer and export surfaces.
Persistence (doctor.ts):
- Enrich DoctorHistoryEntry with issue details (severity, code,
message, unitId) and fix descriptions
- appendDoctorHistory now persists up to 10 issues per entry and
all fix descriptions to doctor-history.jsonl
- Export DoctorHistoryEntry type for consumers
Data layer (visualizer-data.ts):
- Add VisualizerDoctorEntry and VisualizerProgressScore types
- Extend HealthInfo with doctorHistory (last 20 persisted entries)
and progressScore (current in-memory traffic light)
- loadHealth reads doctor-history.jsonl synchronously and snapshots
current progress score when health data exists
TUI visualizer (visualizer-views.ts):
- Health tab now shows "Progress Score" section with traffic light
icon, summary, and all signal details (✓/✗/· prefixed)
- Health tab now shows "Doctor History" section with timestamped
entries, issue messages, and applied fixes
HTML export (export-html.ts):
- Health section includes progress score with colored indicator
and signal breakdown
- Health section includes "Doctor Run History" table with
timestamps, error/warning/fix counts, issue codes, expandable
issue messages, and fix descriptions
* feat: fill remaining health gaps — scope tagging, level notifications, human-readable logs
Gap fills:
Per-milestone/slice scope tagging:
- HealthSnapshot now stores scope (e.g. "M001/S02") from the
doctor run's unit context
- DoctorHistoryEntry persists scope to doctor-history.jsonl
- Visualizer and HTML reports display scope tags per entry
State transition notifications:
- setLevelChangeCallback() registers a handler for progress level
changes (green→yellow, yellow→red, red→green, etc.)
- auto-start.ts wires the callback to ctx.ui.notify on start
- auto.ts clears it on stop
- Notifications include the triggering issue message
Human-readable formatting throughout:
- formatHealthSummary() uses full words: "2 errors, 3 warnings ·
trend degrading · 1 fix applied · 1 of 5 consecutive errors
before escalation · latest: Missing PLAN.md for S03"
- DoctorHistoryEntry stores a human-readable summary field
built from error counts, fix counts, and top issue message
- Visualizer doctor history shows summary instead of "2E 1W 0F"
- HTML export doctor table uses summary column with scope tags
- Post-unit notification says what was fixed ("Doctor: rebuilt
STATE.md; cleared stale lock") instead of "applied 2 fix(es)"
Test updates:
- formatHealthSummary assertions updated for new readable format
* fix: default UAT type to artifact-driven to prevent unnecessary auto-mode pauses (#1651)
When a UAT file has no `## UAT Type` section, `extractUatType()` returns
`undefined`. The fallback was `"human-experience"`, causing `pauseAfterDispatch:
true` in the auto-dispatch rule. Since doctor-generated UAT placeholders never
include a UAT Type section and LLM-executed UATs are always artifact-driven,
the correct default is `"artifact-driven"`.
Closes #1649
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: remove duplicate doctorScope declaration (CI build fix)
* fix: resolve PR1644 regressions in health views and post-unit hook
---------
Co-authored-by: TÂCHES <afromanguy@me.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
161 lines
5.4 KiB
TypeScript
161 lines
5.4 KiB
TypeScript
/**
|
||
* GSD Progress Score — Traffic Light Status Indicator (#1221)
|
||
*
|
||
* Combines existing health signals into a single at-a-glance status:
|
||
* - Green: progressing well
|
||
* - Yellow: struggling (retries, warnings)
|
||
* - Red: stuck (loops, persistent errors, no activity)
|
||
*
|
||
* Purely derived — no stored state. Reads from doctor-proactive health
|
||
* tracking, stuck detection counters, and working-tree activity.
|
||
*/
|
||
|
||
import {
|
||
getHealthTrend,
|
||
getConsecutiveErrorUnits,
|
||
getHealthHistory,
|
||
getLatestHealthIssues,
|
||
getLatestHealthFixes,
|
||
type HealthSnapshot,
|
||
} from "./doctor-proactive.js";
|
||
|
||
// ── Types ──────────────────────────────────────────────────────────────────
|
||
|
||
export type ProgressLevel = "green" | "yellow" | "red";
|
||
|
||
export interface ProgressScore {
|
||
level: ProgressLevel;
|
||
summary: string;
|
||
signals: ProgressSignal[];
|
||
}
|
||
|
||
export interface ProgressSignal {
|
||
kind: "positive" | "negative" | "neutral";
|
||
label: string;
|
||
}
|
||
|
||
function escalateLevel(level: ProgressLevel, next: ProgressLevel): ProgressLevel {
|
||
const ranks: Record<ProgressLevel, number> = {
|
||
green: 0,
|
||
yellow: 1,
|
||
red: 2,
|
||
};
|
||
return ranks[next] > ranks[level] ? next : level;
|
||
}
|
||
|
||
// ── Public API ──────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Compute the current progress score from health signals.
|
||
*/
|
||
export function computeProgressScore(): ProgressScore {
|
||
const signals: ProgressSignal[] = [];
|
||
let level: ProgressLevel = "green";
|
||
|
||
// Check consecutive errors
|
||
const consecutiveErrors = getConsecutiveErrorUnits();
|
||
if (consecutiveErrors >= 3) {
|
||
signals.push({ kind: "negative", label: `${consecutiveErrors} consecutive error units` });
|
||
level = escalateLevel(level, "red");
|
||
} else if (consecutiveErrors >= 1) {
|
||
signals.push({ kind: "negative", label: `${consecutiveErrors} consecutive error unit(s)` });
|
||
level = escalateLevel(level, "yellow");
|
||
}
|
||
|
||
// Check health trend
|
||
const trend = getHealthTrend();
|
||
if (trend === "degrading") {
|
||
signals.push({ kind: "negative", label: "Health trend declining" });
|
||
level = escalateLevel(level, "yellow");
|
||
} else if (trend === "improving") {
|
||
signals.push({ kind: "positive", label: "Health trend improving" });
|
||
} else if (trend === "stable") {
|
||
signals.push({ kind: "neutral", label: "Health trend stable" });
|
||
}
|
||
|
||
// Check recent history
|
||
const history = getHealthHistory();
|
||
if (history.length === 0) {
|
||
signals.push({ kind: "neutral", label: "No health data yet" });
|
||
}
|
||
|
||
// Surface actual doctor issue details when degraded
|
||
if (level !== "green") {
|
||
const latestIssues = getLatestHealthIssues();
|
||
// Show up to 5 most relevant issues (errors first, then warnings)
|
||
const sorted = [...latestIssues].sort((a, b) => {
|
||
const rank = { error: 0, warning: 1, info: 2 };
|
||
return rank[a.severity] - rank[b.severity];
|
||
});
|
||
for (const issue of sorted.slice(0, 5)) {
|
||
signals.push({
|
||
kind: issue.severity === "error" ? "negative" : "neutral",
|
||
label: issue.message,
|
||
});
|
||
}
|
||
|
||
const latestFixes = getLatestHealthFixes();
|
||
for (const fix of latestFixes.slice(0, 3)) {
|
||
signals.push({ kind: "positive", label: `Fixed: ${fix}` });
|
||
}
|
||
}
|
||
|
||
const summary = level === "green"
|
||
? "Progressing well"
|
||
: level === "yellow"
|
||
? "Some issues detected"
|
||
: "Stuck or erroring";
|
||
|
||
return { level, summary, signals };
|
||
}
|
||
|
||
/**
|
||
* Compute progress score with additional context for dashboard display.
|
||
*/
|
||
export function computeProgressScoreWithContext(context: {
|
||
sameUnitCount?: number;
|
||
recoveryCount?: number;
|
||
completedCount?: number;
|
||
}): ProgressScore {
|
||
const base = computeProgressScore();
|
||
|
||
if (context.sameUnitCount && context.sameUnitCount >= 3) {
|
||
base.signals.push({ kind: "negative", label: `Same unit dispatched ${context.sameUnitCount}× consecutively` });
|
||
base.level = escalateLevel(base.level, "red");
|
||
base.summary = "Stuck on same unit";
|
||
} else if (context.sameUnitCount && context.sameUnitCount >= 2) {
|
||
base.signals.push({ kind: "negative", label: `Same unit dispatched ${context.sameUnitCount}×` });
|
||
base.level = escalateLevel(base.level, "yellow");
|
||
}
|
||
|
||
if (context.recoveryCount && context.recoveryCount > 0) {
|
||
base.signals.push({ kind: "negative", label: `${context.recoveryCount} recovery attempts` });
|
||
base.level = escalateLevel(base.level, "yellow");
|
||
}
|
||
|
||
if (context.completedCount && context.completedCount > 0) {
|
||
base.signals.push({ kind: "positive", label: `${context.completedCount} units completed` });
|
||
}
|
||
|
||
return base;
|
||
}
|
||
|
||
/**
|
||
* Format a one-line progress indicator for dashboard/status display.
|
||
*/
|
||
export function formatProgressLine(score: ProgressScore): string {
|
||
const icon = score.level === "green" ? "●" : score.level === "yellow" ? "◐" : "○";
|
||
return `${icon} ${score.summary}`;
|
||
}
|
||
|
||
/**
|
||
* Format a multi-line progress report.
|
||
*/
|
||
export function formatProgressReport(score: ProgressScore): string {
|
||
const lines = [formatProgressLine(score)];
|
||
for (const signal of score.signals) {
|
||
const prefix = signal.kind === "positive" ? " ✓" : signal.kind === "negative" ? " ✗" : " ·";
|
||
lines.push(`${prefix} ${signal.label}`);
|
||
}
|
||
return lines.join("\n");
|
||
}
|