singularity-forge/src/resources/extensions/gsd/progress-score.ts
Jeremy McSpadden 94fe53b527 feat: health check phase 2 — real-time doctor issue visibility across widget, visualizer, and HTML reports (#1644)
* 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>
2026-03-20 15:33:40 -06:00

161 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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");
}