@lsctech/polaris 0.5.6 → 0.5.7

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.
@@ -253,6 +253,11 @@ function normalizeQcScoreSummary(raw) {
253
253
  category_breakdown: q.category_breakdown ?? {},
254
254
  recurring_child_signals: Array.isArray(q.recurring_child_signals) ? q.recurring_child_signals : [],
255
255
  recurring_provider_signals: Array.isArray(q.recurring_provider_signals) ? q.recurring_provider_signals : [],
256
+ repair_loop: q.repair_loop ?? null,
257
+ noisy_providers: Array.isArray(q.noisy_providers) ? q.noisy_providers : [],
258
+ has_repair_failures: q.has_repair_failures ?? false,
259
+ unresolved_high_severity: q.unresolved_high_severity ?? 0,
260
+ max_round_exhausted: q.max_round_exhausted ?? false,
256
261
  };
257
262
  }
258
263
  /**
@@ -25,6 +25,7 @@ exports.scoreRun = scoreRun;
25
25
  const node_fs_1 = require("node:fs");
26
26
  const node_path_1 = require("node:path");
27
27
  const artifacts_js_1 = require("../qc/artifacts.js");
28
+ const store_js_1 = require("../cluster-state/store.js");
28
29
  const gates_js_1 = require("./gates.js");
29
30
  // ──────────────────────────────────────────────
30
31
  // Diagnosis hints table
@@ -196,6 +197,7 @@ function loadRunArtifacts(repoRoot, runId) {
196
197
  workerResultContracts = extractWorkerResultContracts(resultPackets);
197
198
  }
198
199
  const qcResults = loadQcArtifacts(clusterId, repoRoot);
200
+ const clusterState = clusterId ? (0, store_js_1.readClusterStateSync)(clusterId, repoRoot) : null;
199
201
  return {
200
202
  runId,
201
203
  runDir,
@@ -206,6 +208,7 @@ function loadRunArtifacts(repoRoot, runId) {
206
208
  workerResultContracts,
207
209
  telemetryEvents,
208
210
  qcResults,
211
+ clusterState,
209
212
  };
210
213
  }
211
214
  // ──────────────────────────────────────────────
@@ -247,6 +250,159 @@ function computeQcPenalty(weightedOpenScore) {
247
250
  // Soft penalty that grows with weighted severity but does not dominate the score.
248
251
  return weightedOpenScore === 0 ? 0 : weightedOpenScore / (weightedOpenScore + 20);
249
252
  }
253
+ function zeroProviderAttemptSummary() {
254
+ return { total: 0, success: 0, failure: 0, fallback: 0, skipped: 0, all_providers_failed: false };
255
+ }
256
+ function zeroRepairLoopSummary() {
257
+ return {
258
+ status: "not-configured",
259
+ rounds_completed: 0,
260
+ max_rounds: 0,
261
+ packets_compiled: 0,
262
+ packets_completed: 0,
263
+ packets_failed: 0,
264
+ rerun_outcome: null,
265
+ provider_attempts: zeroProviderAttemptSummary(),
266
+ };
267
+ }
268
+ function mapTerminalOutcome(outcome) {
269
+ switch (outcome) {
270
+ case "pass":
271
+ return "passed";
272
+ case "no-repairable":
273
+ return "no-repairable";
274
+ case "max-rounds":
275
+ return "max-rounds";
276
+ case "all-providers-failed":
277
+ return "all-providers-failed";
278
+ case "operator-review":
279
+ return "operator-review";
280
+ case "medic-referral":
281
+ return "medic-referral";
282
+ case "qc-disabled":
283
+ return "not-configured";
284
+ default:
285
+ return "unknown";
286
+ }
287
+ }
288
+ function providerAttemptStatusFromResult(result) {
289
+ if (result.providerAttempt)
290
+ return result.providerAttempt.status;
291
+ if (result.allProvidersFailed || result.status === "failed")
292
+ return "failure";
293
+ if (result.status === "skipped")
294
+ return "skipped";
295
+ if (result.findings.length > 0 || result.status === "findings" || result.status === "blocked")
296
+ return "success";
297
+ if (result.status === "passed")
298
+ return "success";
299
+ return undefined;
300
+ }
301
+ function computeProviderAttemptSummary(qcResults, telemetryEvents) {
302
+ const summary = zeroProviderAttemptSummary();
303
+ for (const result of qcResults) {
304
+ const status = providerAttemptStatusFromResult(result);
305
+ if (status) {
306
+ summary.total += 1;
307
+ if (status === "success")
308
+ summary.success += 1;
309
+ else if (status === "failure")
310
+ summary.failure += 1;
311
+ else if (status === "fallback")
312
+ summary.fallback += 1;
313
+ else if (status === "skipped")
314
+ summary.skipped += 1;
315
+ }
316
+ if (result.allProvidersFailed)
317
+ summary.all_providers_failed = true;
318
+ }
319
+ // Telemetry fallback events augment the fallback count when providerAttempt was not written.
320
+ for (const ev of telemetryEvents) {
321
+ const rec = asRecord(ev);
322
+ if (rec?.["event"] === "provider-fallback-attempted")
323
+ summary.fallback += 1;
324
+ }
325
+ return summary;
326
+ }
327
+ function computeQcRepairLoopSummary(qcResults, clusterState, telemetryEvents, currentState) {
328
+ if (qcResults.length === 0)
329
+ return zeroRepairLoopSummary();
330
+ const currentStateRec = asRecord(currentState);
331
+ const loopState = currentStateRec?.["qc_repair_loop"];
332
+ const terminalOutcome = clusterState?.qc_repair_outcome ??
333
+ (loopState?.terminal_outcome ? String(loopState.terminal_outcome) : null);
334
+ const terminalEvents = telemetryEvents
335
+ .map(asRecord)
336
+ .filter((e) => e !== undefined && e["event"] === "qc-repair-loop-terminal");
337
+ const lastTerminal = terminalEvents[terminalEvents.length - 1];
338
+ const manifestEvents = telemetryEvents
339
+ .map(asRecord)
340
+ .filter((e) => e !== undefined && e["event"] === "qc-repair-manifest-compiled");
341
+ const failureEvents = telemetryEvents
342
+ .map(asRecord)
343
+ .filter((e) => e !== undefined && e["event"] === "qc-repair-worker-failures");
344
+ const failedPacketIds = new Set();
345
+ for (const ev of failureEvents) {
346
+ const ids = ev["failed_packet_ids"];
347
+ if (Array.isArray(ids)) {
348
+ for (const id of ids) {
349
+ if (typeof id === "string")
350
+ failedPacketIds.add(id);
351
+ }
352
+ }
353
+ }
354
+ const rerunEvents = telemetryEvents
355
+ .map(asRecord)
356
+ .filter((e) => e !== undefined && e["event"] === "qc-repair-rerun-complete");
357
+ const lastRerun = rerunEvents[rerunEvents.length - 1];
358
+ let status;
359
+ if (terminalOutcome) {
360
+ status = mapTerminalOutcome(terminalOutcome);
361
+ }
362
+ else if (lastTerminal) {
363
+ status = mapTerminalOutcome(asString(lastTerminal["outcome"]));
364
+ }
365
+ else if (loopState) {
366
+ status = "in-progress";
367
+ }
368
+ else {
369
+ status = "not-run";
370
+ }
371
+ const roundsCompleted = asNumber(lastTerminal?.["rounds_completed"]) ??
372
+ (loopState?.current_round ?? 0);
373
+ const maxRounds = asNumber(lastTerminal?.["max_rounds"]) ??
374
+ (loopState?.max_rounds ?? 0);
375
+ const packetsCompiled = manifestEvents.reduce((sum, e) => {
376
+ const n = asNumber(e["packet_count"]);
377
+ return sum + (n ?? 0);
378
+ }, 0);
379
+ const packetsFailed = failedPacketIds.size;
380
+ const packetsCompleted = status === "not-run" || status === "not-configured"
381
+ ? 0
382
+ : Math.max(0, packetsCompiled - packetsFailed);
383
+ let rerunOutcome = null;
384
+ if (lastRerun) {
385
+ const action = asString(lastRerun["action"]);
386
+ if (action === "pass")
387
+ rerunOutcome = "pass";
388
+ else if (action === "findings")
389
+ rerunOutcome = "findings";
390
+ else if (action === "failure" || action === "error")
391
+ rerunOutcome = "failed";
392
+ else if (action === "skipped")
393
+ rerunOutcome = "skipped";
394
+ }
395
+ return {
396
+ status,
397
+ rounds_completed: roundsCompleted,
398
+ max_rounds: maxRounds,
399
+ packets_compiled: packetsCompiled,
400
+ packets_completed: packetsCompleted,
401
+ packets_failed: packetsFailed,
402
+ rerun_outcome: rerunOutcome,
403
+ provider_attempts: computeProviderAttemptSummary(qcResults, telemetryEvents),
404
+ };
405
+ }
250
406
  /**
251
407
  * Computes a QcScoreSummary from the QC results loaded for this run.
252
408
  * Returns null when no QC results are present.
@@ -263,7 +419,7 @@ function computeQcPenalty(weightedOpenScore) {
263
419
  * a critical/high-confidence finding hurts SOL scoring more than a low-severity
264
420
  * or uncertain finding.
265
421
  */
266
- function computeQcSummary(qcResults) {
422
+ function computeQcSummary(qcResults, clusterState = null, telemetryEvents = [], currentState = null) {
267
423
  if (qcResults.length === 0)
268
424
  return null;
269
425
  let total = 0;
@@ -362,6 +518,11 @@ function computeQcSummary(qcResults) {
362
518
  .map(([provider, value]) => ({ provider, ...value }))
363
519
  .filter((s) => s.weighted_score > 0)
364
520
  .sort((a, b) => b.weighted_score - a.weighted_score || a.provider.localeCompare(b.provider));
521
+ const repairLoop = computeQcRepairLoopSummary(qcResults, clusterState, telemetryEvents, currentState);
522
+ const noisyProviders = Array.from(providerBreakdown.entries())
523
+ .filter(([, counts]) => counts.total >= 2 && counts.unvalidated / counts.total >= 0.5)
524
+ .map(([provider]) => provider)
525
+ .sort();
365
526
  return {
366
527
  total_findings: total,
367
528
  blocking_findings: blocking,
@@ -379,6 +540,11 @@ function computeQcSummary(qcResults) {
379
540
  category_breakdown: Object.fromEntries(categoryBreakdown),
380
541
  recurring_child_signals: recurringChildSignals,
381
542
  recurring_provider_signals: recurringProviderSignals,
543
+ repair_loop: repairLoop,
544
+ noisy_providers: noisyProviders,
545
+ has_repair_failures: repairLoop.packets_failed > 0 || repairLoop.status === "medic-referral",
546
+ unresolved_high_severity: openBySeverity.critical + openBySeverity.high,
547
+ max_round_exhausted: repairLoop.status === "max-rounds",
382
548
  };
383
549
  }
384
550
  function asRecord(value) {
@@ -386,6 +552,12 @@ function asRecord(value) {
386
552
  ? value
387
553
  : undefined;
388
554
  }
555
+ function asString(value) {
556
+ return typeof value === "string" ? value : null;
557
+ }
558
+ function asNumber(value) {
559
+ return typeof value === "number" ? value : null;
560
+ }
389
561
  function asStringArray(value) {
390
562
  if (!Array.isArray(value))
391
563
  return [];
@@ -485,7 +657,7 @@ function scoreRun(repoRoot, runId) {
485
657
  const score = computeScore(gateResults);
486
658
  const diagnosisHints = buildDiagnosisHints(failedGates);
487
659
  const routerOutcomes = summarizeRouterOutcomes(artifacts);
488
- const qcSummary = computeQcSummary(artifacts.qcResults);
660
+ const qcSummary = computeQcSummary(artifacts.qcResults, artifacts.clusterState, artifacts.telemetryEvents, artifacts.currentState);
489
661
  const clusterId = artifacts.currentState &&
490
662
  typeof artifacts.currentState === "object" &&
491
663
  !Array.isArray(artifacts.currentState)
@@ -293,9 +293,11 @@ function buildRouterEvidence(artifacts) {
293
293
  // QC evidence
294
294
  // ──────────────────────────────────────────────
295
295
  function buildQcEvidence(artifacts) {
296
+ const disabledOutcome = artifacts.clusterState?.qc_repair_outcome;
297
+ const isDisabled = disabledOutcome === "qc-disabled";
296
298
  if (artifacts.qcResults.length === 0) {
297
- return {
298
- availability: "future",
299
+ const base = {
300
+ availability: isDisabled ? "unavailable" : "future",
299
301
  qc_run_count: 0,
300
302
  total_findings: 0,
301
303
  blocking_findings: 0,
@@ -307,9 +309,32 @@ function buildQcEvidence(artifacts) {
307
309
  qc_penalty: 0,
308
310
  blocks_delivery: false,
309
311
  open_by_severity: { critical: 0, high: 0, medium: 0, low: 0, info: 0 },
312
+ provider_breakdown: {},
313
+ repair_loop: {
314
+ status: isDisabled ? "not-configured" : "not-run",
315
+ rounds_completed: 0,
316
+ max_rounds: 0,
317
+ packets_compiled: 0,
318
+ packets_completed: 0,
319
+ packets_failed: 0,
320
+ rerun_outcome: null,
321
+ provider_attempts: {
322
+ total: 0,
323
+ success: 0,
324
+ failure: 0,
325
+ fallback: 0,
326
+ skipped: 0,
327
+ all_providers_failed: false,
328
+ },
329
+ },
330
+ noisy_providers: [],
331
+ has_repair_failures: false,
332
+ unresolved_high_severity: 0,
333
+ max_round_exhausted: false,
310
334
  };
335
+ return base;
311
336
  }
312
- const summary = (0, score_js_1.computeQcSummary)(artifacts.qcResults);
337
+ const summary = (0, score_js_1.computeQcSummary)(artifacts.qcResults, artifacts.clusterState, artifacts.telemetryEvents, artifacts.currentState);
313
338
  if (!summary) {
314
339
  // computeQcSummary only returns null when qcResults is empty; handled above.
315
340
  // ponytail: defensive branch to satisfy type narrowing
@@ -326,6 +351,12 @@ function buildQcEvidence(artifacts) {
326
351
  qc_penalty: 0,
327
352
  blocks_delivery: false,
328
353
  open_by_severity: { critical: 0, high: 0, medium: 0, low: 0, info: 0 },
354
+ provider_breakdown: {},
355
+ repair_loop: null,
356
+ noisy_providers: [],
357
+ has_repair_failures: false,
358
+ unresolved_high_severity: 0,
359
+ max_round_exhausted: false,
329
360
  };
330
361
  }
331
362
  return {
@@ -341,6 +372,12 @@ function buildQcEvidence(artifacts) {
341
372
  qc_penalty: summary.qc_penalty,
342
373
  blocks_delivery: summary.blocks_delivery,
343
374
  open_by_severity: { ...summary.open_by_severity },
375
+ provider_breakdown: { ...summary.provider_breakdown },
376
+ repair_loop: summary.repair_loop,
377
+ noisy_providers: [...summary.noisy_providers],
378
+ has_repair_failures: summary.has_repair_failures,
379
+ unresolved_high_severity: summary.unresolved_high_severity,
380
+ max_round_exhausted: summary.max_round_exhausted,
344
381
  };
345
382
  }
346
383
  // ──────────────────────────────────────────────
@@ -19,6 +19,8 @@ exports.generateRecommendations = generateRecommendations;
19
19
  exports.recommendationToProposal = recommendationToProposal;
20
20
  exports.recommendationsToProposals = recommendationsToProposals;
21
21
  exports.formatRecommendationsCli = formatRecommendationsCli;
22
+ exports.generateQcRecommendations = generateQcRecommendations;
23
+ exports.formatQcRecommendations = formatQcRecommendations;
22
24
  const sol_report_js_1 = require("./sol-report.js");
23
25
  // ──────────────────────────────────────────────
24
26
  // Constants and helpers
@@ -298,3 +300,83 @@ function formatRecommendationsCli(report) {
298
300
  }
299
301
  return lines.join("\n");
300
302
  }
303
+ function makeQcRecommendation(id, category, actionType, affected, proposedAction, rationale, confidence, evidenceRunId) {
304
+ return {
305
+ id,
306
+ category,
307
+ action_type: actionType,
308
+ affected,
309
+ proposed_action: proposedAction,
310
+ confidence: clamp01(confidence),
311
+ evidence: {
312
+ group_key: `run_id=${evidenceRunId}`,
313
+ grouped_by: ["run_id"],
314
+ count: 1,
315
+ mean_composite: null,
316
+ min_composite: null,
317
+ max_composite: null,
318
+ mean_foreman_composite: null,
319
+ mean_worker_composite: null,
320
+ run_ids: [evidenceRunId],
321
+ },
322
+ rationale,
323
+ };
324
+ }
325
+ /**
326
+ * Generate QC repair-loop follow-up recommendations from a single run's
327
+ * SOL evidence. These are advisory signals for noisy providers, repeated
328
+ * repair failures, unresolved high-severity findings, and max-round exhaustion.
329
+ */
330
+ function generateQcRecommendations(evidence) {
331
+ const recommendations = [];
332
+ const qc = evidence.qc;
333
+ const runId = evidence.run_id;
334
+ if (qc.availability !== "available") {
335
+ return {
336
+ generated_at: new Date().toISOString(),
337
+ evidence_run_id: runId,
338
+ recommendations,
339
+ };
340
+ }
341
+ for (const provider of qc.noisy_providers) {
342
+ const counts = qc.provider_breakdown[provider] ?? { total: 0, blocking: 0, unvalidated: 0 };
343
+ const confidence = counts.total > 0 ? counts.unvalidated / counts.total : 0;
344
+ recommendations.push(makeQcRecommendation(`qc-noisy-provider:${provider}`, "provider_policy", "analyze", { provider }, `Review QC provider '${provider}' for noisy/unvalidated findings; consider raising attribution confidence thresholds or disabling the provider for high-risk routes.`, `${counts.unvalidated}/${counts.total} findings from '${provider}' are unvalidated noise.`, confidence, runId));
345
+ }
346
+ if (qc.has_repair_failures) {
347
+ const failed = qc.repair_loop?.packets_failed ?? 0;
348
+ recommendations.push(makeQcRecommendation(`qc-repair-failure:${runId}`, "qc_follow_up", "implement", {}, "Repeated repair worker failures detected — inspect repair packet scope, validation commands, and Medic referral path.", `${failed} repair packet(s) failed; consider tightening acceptance criteria or escalating to Medic.`, 0.9, runId));
349
+ }
350
+ if (qc.unresolved_high_severity > 0) {
351
+ recommendations.push(makeQcRecommendation(`qc-unresolved-high-severity:${runId}`, "qc_follow_up", "analyze", {}, `${qc.unresolved_high_severity} unresolved critical/high QC findings remain — triage before delivery.`, `${qc.unresolved_high_severity} critical/high findings are still open after repair loop.`, Math.min(1, qc.unresolved_high_severity / 5), runId));
352
+ }
353
+ if (qc.max_round_exhausted) {
354
+ recommendations.push(makeQcRecommendation(`qc-max-rounds:${runId}`, "qc_follow_up", "analyze", {}, "QC repair loop exhausted max rounds — review provider noise, repair packet scope, and escalation policy.", `Repair loop reached round ${qc.repair_loop?.rounds_completed ?? 0}/${qc.repair_loop?.max_rounds ?? 0} without passing.`, 0.85, runId));
355
+ }
356
+ return {
357
+ generated_at: new Date().toISOString(),
358
+ evidence_run_id: runId,
359
+ recommendations,
360
+ };
361
+ }
362
+ function formatQcRecommendations(report) {
363
+ const lines = [];
364
+ lines.push("SOL QC Follow-Up Recommendations");
365
+ lines.push(`Generated: ${report.generated_at}`);
366
+ lines.push(`Run: ${report.evidence_run_id}`);
367
+ lines.push("");
368
+ if (report.recommendations.length === 0) {
369
+ lines.push("No QC follow-up signals detected.");
370
+ return lines.join("\n") + "\n";
371
+ }
372
+ for (const r of report.recommendations) {
373
+ lines.push(`[${r.action_type}] ${r.id}`);
374
+ lines.push(` Category: ${r.category}`);
375
+ lines.push(` Affected: ${fmtAffected(r.affected)}`);
376
+ lines.push(` Confidence: ${(r.confidence * 100).toFixed(1)}%`);
377
+ lines.push(` Action: ${r.proposed_action}`);
378
+ lines.push(` Rationale: ${r.rationale}`);
379
+ lines.push("");
380
+ }
381
+ return lines.join("\n");
382
+ }
@@ -11,6 +11,7 @@
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.generateReport = generateReport;
13
13
  exports.formatReportCli = formatReportCli;
14
+ exports.formatQcEvidence = formatQcEvidence;
14
15
  // ──────────────────────────────────────────────
15
16
  // Helpers
16
17
  // ──────────────────────────────────────────────
@@ -151,3 +152,46 @@ function formatReportCli(report) {
151
152
  function padRight(s, n) {
152
153
  return s.length >= n ? s : s + " ".repeat(n - s.length);
153
154
  }
155
+ /**
156
+ * Render a human-readable summary of QC evidence and repair-loop outcomes.
157
+ */
158
+ function formatQcEvidence(qc) {
159
+ const lines = [];
160
+ lines.push("QC Evidence Summary");
161
+ lines.push(` availability: ${qc.availability}`);
162
+ lines.push(` qc_run_count: ${qc.qc_run_count}`);
163
+ lines.push(` total_findings: ${qc.total_findings}`);
164
+ lines.push(` repaired: ${qc.repaired_findings}, open: ${qc.total_findings - qc.repaired_findings - qc.autofixed_findings - qc.waived_findings}`);
165
+ lines.push(` unresolved high/critical: ${qc.unresolved_high_severity}`);
166
+ lines.push(` blocks_delivery: ${qc.blocks_delivery}`);
167
+ lines.push("");
168
+ const providers = Object.entries(qc.provider_breakdown);
169
+ if (providers.length > 0) {
170
+ lines.push("Provider breakdown:");
171
+ for (const [provider, counts] of providers) {
172
+ lines.push(` ${provider}: total=${counts.total}, blocking=${counts.blocking}, unvalidated=${counts.unvalidated}`);
173
+ }
174
+ lines.push("");
175
+ }
176
+ if (qc.repair_loop) {
177
+ const loop = qc.repair_loop;
178
+ lines.push("Repair loop:");
179
+ lines.push(` status: ${loop.status}`);
180
+ lines.push(` rounds: ${loop.rounds_completed}/${loop.max_rounds}`);
181
+ lines.push(` packets: ${loop.packets_compiled} compiled, ${loop.packets_completed} completed, ${loop.packets_failed} failed`);
182
+ lines.push(` rerun_outcome: ${loop.rerun_outcome ?? "n/a"}`);
183
+ lines.push(` provider_attempts: total=${loop.provider_attempts.total}, success=${loop.provider_attempts.success}, failure=${loop.provider_attempts.failure}, fallback=${loop.provider_attempts.fallback}, skipped=${loop.provider_attempts.skipped}`);
184
+ lines.push(` all_providers_failed: ${loop.provider_attempts.all_providers_failed}`);
185
+ lines.push("");
186
+ }
187
+ if (qc.noisy_providers.length > 0) {
188
+ lines.push(`Noisy providers: ${qc.noisy_providers.join(", ")}`);
189
+ }
190
+ if (qc.has_repair_failures) {
191
+ lines.push("Repeated repair failures detected.");
192
+ }
193
+ if (qc.max_round_exhausted) {
194
+ lines.push("Max repair rounds exhausted.");
195
+ }
196
+ return lines.join("\n").trim() + "\n";
197
+ }
@@ -207,6 +207,48 @@ function scoreForemanCompletion(ev) {
207
207
  detail: `succeeded=${succeeded}/${total}`,
208
208
  });
209
209
  }
210
+ /**
211
+ * qc_repair_loop: Observe QC repair loop outcomes.
212
+ *
213
+ * Scores repair loop terminal states without treating provider findings as
214
+ * ground truth. Best outcome = loop passed or no repairable findings.
215
+ * Worst = all providers failed, operator review required, or Medic referral.
216
+ * Skipped when QC is not configured or no repair loop ran.
217
+ */
218
+ function scoreForemanQcRepairLoop(ev) {
219
+ if (ev.qc.availability === "future" || ev.qc.availability === "unavailable") {
220
+ return skipped("qc_repair_loop", `QC evidence availability=${ev.qc.availability}`);
221
+ }
222
+ const loop = ev.qc.repair_loop;
223
+ if (!loop) {
224
+ return skipped("qc_repair_loop", "no QC repair loop data available");
225
+ }
226
+ const { status, rounds_completed, max_rounds, packets_compiled, packets_failed, rerun_outcome } = loop;
227
+ switch (status) {
228
+ case "passed":
229
+ case "repaired":
230
+ case "no-repairable":
231
+ return dim("qc_repair_loop", 1.0, "high", {
232
+ detail: `status=${status}, rounds=${rounds_completed}/${max_rounds}, packets=${packets_compiled}, rerun=${rerun_outcome ?? "n/a"}`,
233
+ });
234
+ case "max-rounds":
235
+ return dim("qc_repair_loop", 0.5, "high", {
236
+ detail: `status=${status}, rounds=${rounds_completed}/${max_rounds}, packets=${packets_compiled}`,
237
+ });
238
+ case "all-providers-failed":
239
+ case "operator-review":
240
+ case "medic-referral":
241
+ return dim("qc_repair_loop", 0.0, "high", {
242
+ detail: `status=${status}, failed_packets=${packets_failed}, rerun=${rerun_outcome ?? "n/a"}`,
243
+ });
244
+ case "not-run":
245
+ case "not-configured":
246
+ case "in-progress":
247
+ case "unknown":
248
+ default:
249
+ return skipped("qc_repair_loop", `QC repair loop status=${status}`);
250
+ }
251
+ }
210
252
  /**
211
253
  * recovery: State repair detection.
212
254
  * Score = 1.0 when no medic/repair artifacts detected; 0.0 when repair required.
@@ -388,10 +430,11 @@ function computeForemanScore(ev) {
388
430
  const scopeDim = scoreForemanScope(ev);
389
431
  const completionDim = scoreForemanCompletion(ev);
390
432
  const recoveryDim = scoreForemanRecovery(ev);
433
+ const qcRepairLoopDim = scoreForemanQcRepairLoop(ev);
391
434
  const dims = [
392
435
  tokenDim, durationDim, interventionDim, preAnalysisDim,
393
436
  dependencyDim, dispatchDim, evidenceValidationDim, scopeDim,
394
- completionDim, recoveryDim,
437
+ completionDim, recoveryDim, qcRepairLoopDim,
395
438
  ];
396
439
  const { score: composite_score, confidence: composite_confidence } = composite(dims);
397
440
  return {
@@ -407,6 +450,7 @@ function computeForemanScore(ev) {
407
450
  scope: scopeDim,
408
451
  completion: completionDim,
409
452
  recovery: recoveryDim,
453
+ qc_repair_loop: qcRepairLoopDim,
410
454
  };
411
455
  }
412
456
  /**
@@ -119,5 +119,6 @@ exports.DEFAULT_CONFIG = {
119
119
  maxRuns: 10,
120
120
  },
121
121
  routes: {},
122
+ maxRepairRounds: 2,
122
123
  },
123
124
  };