@lsctech/polaris 0.5.8 → 0.5.9

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.
@@ -0,0 +1,246 @@
1
+ "use strict";
2
+ /**
3
+ * SOL → run-health bridge.
4
+ *
5
+ * Evaluates SOL score reports against operator-configured thresholds and,
6
+ * when policy enables it, appends run-health symptoms so that Medic can be
7
+ * engaged even when no worker explicitly wrote symptoms.
8
+ *
9
+ * Design rules:
10
+ * - SOL is ADVISORY by default. No symptoms are created unless the operator
11
+ * sets `sol.thresholds.enabled = true` AND at least one policy flag
12
+ * (`createRunHealthReport` or `requireMedic`) is true.
13
+ * - Symptoms reference the SOL evidence file as their source; they do NOT
14
+ * mutate raw metric artifacts.
15
+ * - Each threshold crossing produces exactly one symptom per evaluation.
16
+ * Re-evaluating the same run does not duplicate symptoms because the
17
+ * symptom id encodes the threshold name.
18
+ * - Never throws — threshold evaluation must not block the run.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.detectSolThresholdCrossings = detectSolThresholdCrossings;
22
+ exports.evaluateSolThresholds = evaluateSolThresholds;
23
+ const index_js_1 = require("../run-health/index.js");
24
+ // ── Defaults ─────────────────────────────────────────────────────────────────
25
+ const DEFAULT_LOW_COMPOSITE_SCORE = 0.4;
26
+ const DEFAULT_QC_REPAIR_LOOP_FAILURE_STATUSES = [
27
+ "max-rounds",
28
+ "medic-referral",
29
+ "all-providers-failed",
30
+ ];
31
+ const DEFAULT_REPEATED_PROVIDER_FAILURES = 3;
32
+ const DEFAULT_FOREMAN_INTERVENTION_COUNT = 2;
33
+ const DEFAULT_STALE_WRONG_RUN_TELEMETRY = true;
34
+ const DEFAULT_VALIDATION_FAILURES = 2;
35
+ // ── Helpers ───────────────────────────────────────────────────────────────────
36
+ function makeSolSourceActor() {
37
+ return { role: "sol" };
38
+ }
39
+ function makeSolSymptom(crossing) {
40
+ return {
41
+ // Deterministic id: code only (no UUID) so repeated evaluations of the
42
+ // same run are idempotent — the same threshold crossing produces the
43
+ // same id, and a caller that deduplicates by id will skip the duplicate.
44
+ // ponytail: when run-health supports upsert-by-id, switch to that.
45
+ id: `sol:${crossing.code}`,
46
+ severity: solThresholdSeverity(crossing.code),
47
+ code: crossing.code,
48
+ message: crossing.message,
49
+ source_actor: makeSolSourceActor(),
50
+ evidence_refs: crossing.evidenceRefs,
51
+ occurred_at: new Date().toISOString(),
52
+ };
53
+ }
54
+ function solThresholdSeverity(code) {
55
+ switch (code) {
56
+ case "sol-qc-repair-loop-failure":
57
+ case "sol-repeated-provider-failures":
58
+ return "high";
59
+ case "sol-low-composite-score":
60
+ case "sol-high-foreman-intervention":
61
+ case "sol-validation-failures":
62
+ return "medium";
63
+ case "sol-stale-wrong-run-telemetry":
64
+ return "high";
65
+ default:
66
+ return "medium";
67
+ }
68
+ }
69
+ function upsertSolSymptom(runId, clusterId, symptom, repoRoot) {
70
+ const existing = (0, index_js_1.readRunHealthReport)(runId, repoRoot);
71
+ if (!existing) {
72
+ (0, index_js_1.createRunHealthReport)({
73
+ runId,
74
+ clusterId,
75
+ firstSymptom: symptom,
76
+ sourceActor: makeSolSourceActor(),
77
+ repoRoot,
78
+ });
79
+ }
80
+ else {
81
+ // Deduplicate: skip if a symptom with the same id already exists
82
+ if (existing.symptoms.some((s) => s.id === symptom.id)) {
83
+ return;
84
+ }
85
+ (0, index_js_1.appendSymptom)(runId, symptom, repoRoot);
86
+ }
87
+ }
88
+ // ── Threshold evaluation ──────────────────────────────────────────────────────
89
+ /**
90
+ * Detect which thresholds are crossed given the SOL score report.
91
+ * Returns an array of crossing descriptors without any I/O side effects.
92
+ */
93
+ function detectSolThresholdCrossings(scoreReport, thresholdsConfig, evidencePaths) {
94
+ const crossings = [];
95
+ const lowCompositeThreshold = thresholdsConfig.low_composite_score ?? DEFAULT_LOW_COMPOSITE_SCORE;
96
+ const qcFailureStatuses = thresholdsConfig.qc_repair_loop_failure_statuses ?? DEFAULT_QC_REPAIR_LOOP_FAILURE_STATUSES;
97
+ const providerFailureThreshold = thresholdsConfig.repeated_provider_failures ?? DEFAULT_REPEATED_PROVIDER_FAILURES;
98
+ const interventionThreshold = thresholdsConfig.foreman_intervention_count ?? DEFAULT_FOREMAN_INTERVENTION_COUNT;
99
+ const checkStaleWrongRun = thresholdsConfig.stale_wrong_run_telemetry ?? DEFAULT_STALE_WRONG_RUN_TELEMETRY;
100
+ const validationFailureThreshold = thresholdsConfig.validation_failures ?? DEFAULT_VALIDATION_FAILURES;
101
+ // 1. Low composite score
102
+ if (scoreReport.run_composite_score !== null &&
103
+ scoreReport.run_composite_score < lowCompositeThreshold) {
104
+ crossings.push({
105
+ code: "sol-low-composite-score",
106
+ message: `SOL run composite score ${scoreReport.run_composite_score.toFixed(4)} is below ` +
107
+ `configured threshold ${lowCompositeThreshold} — run health may be degraded.`,
108
+ evidenceRefs: evidencePaths,
109
+ });
110
+ }
111
+ // 2. QC repair-loop failure state
112
+ const foremanReport = scoreReport.foreman;
113
+ const qcRepairLoopDetail = foremanReport.qc_repair_loop?.detail ?? "";
114
+ for (const status of qcFailureStatuses) {
115
+ if (qcRepairLoopDetail.includes(`status=${status}`)) {
116
+ crossings.push({
117
+ code: "sol-qc-repair-loop-failure",
118
+ message: `SOL evidence shows QC repair loop reached failure status "${status}" — ` +
119
+ `Medic consultation is recommended.`,
120
+ evidenceRefs: evidencePaths,
121
+ });
122
+ break; // One symptom per run for QC repair-loop failures
123
+ }
124
+ }
125
+ // Also detect via qc_repair_loop score == 0
126
+ if (foremanReport.qc_repair_loop?.score !== null &&
127
+ foremanReport.qc_repair_loop?.score === 0 &&
128
+ crossings.every((c) => c.code !== "sol-qc-repair-loop-failure")) {
129
+ crossings.push({
130
+ code: "sol-qc-repair-loop-failure",
131
+ message: `SOL evidence shows QC repair loop score 0.0 (worst outcome) — ` +
132
+ `Medic consultation is recommended.`,
133
+ evidenceRefs: evidencePaths,
134
+ });
135
+ }
136
+ // 3. Repeated provider failures — inferred from worker composite scores ≤ 0
137
+ // and workers_failed count (available through foreman scores proxy).
138
+ const failedWorkers = Object.values(scoreReport.workers).filter((w) => w.composite_score !== null && w.composite_score <= 0);
139
+ if (failedWorkers.length >= providerFailureThreshold) {
140
+ crossings.push({
141
+ code: "sol-repeated-provider-failures",
142
+ message: `SOL evidence shows ${failedWorkers.length} worker(s) with zero composite score ` +
143
+ `(threshold: ${providerFailureThreshold}) — possible repeated provider failures.`,
144
+ evidenceRefs: evidencePaths,
145
+ });
146
+ }
147
+ // 4. High Foreman intervention count
148
+ // intervention.score: 0.0 = user_intervened, 0.5 = foreman_intervened
149
+ const interventionScore = foremanReport.intervention?.score;
150
+ if (interventionScore !== null && interventionScore !== undefined) {
151
+ // pre_analysis dimension uses escalation_events as a proxy
152
+ // intervention score < 0.5 means foreman_intervened; 0.0 means user_intervened
153
+ // We use the foreman.pre_analysis detail to extract escalation_events
154
+ const preAnalysisDetail = foremanReport.pre_analysis?.detail ?? "";
155
+ const escalationMatch = /escalation_events=(\d+)/.exec(preAnalysisDetail);
156
+ const escalationEvents = escalationMatch ? parseInt(escalationMatch[1], 10) : 0;
157
+ if (escalationEvents > interventionThreshold) {
158
+ crossings.push({
159
+ code: "sol-high-foreman-intervention",
160
+ message: `SOL evidence shows ${escalationEvents} escalation event(s) ` +
161
+ `(threshold: ${interventionThreshold}) — possible Foreman intervention loop.`,
162
+ evidenceRefs: evidencePaths,
163
+ });
164
+ }
165
+ }
166
+ // 5. Stale / wrong-run telemetry
167
+ // Detected via foreman.dispatch and foreman.duration dimension details
168
+ if (checkStaleWrongRun) {
169
+ const dispatchDetail = foremanReport.dispatch?.detail ?? "";
170
+ const durationDetail = foremanReport.duration?.detail ?? "";
171
+ const hasRedispatch = /redispatched=\d+\//.exec(dispatchDetail);
172
+ const redispatchCount = hasRedispatch
173
+ ? parseInt(/redispatched=(\d+)/.exec(dispatchDetail)?.[1] ?? "0", 10)
174
+ : 0;
175
+ // dispatch_epoch >> continue_epoch suggests wrong-run telemetry
176
+ const dispatchEpochMatch = /dispatch_epoch=(\d+)/.exec(durationDetail);
177
+ const continueEpochMatch = /continue_epoch=(\d+)/.exec(durationDetail);
178
+ const dispatchEpoch = dispatchEpochMatch ? parseInt(dispatchEpochMatch[1], 10) : 0;
179
+ const continueEpoch = continueEpochMatch ? parseInt(continueEpochMatch[1], 10) : 0;
180
+ const epochOverhead = dispatchEpoch > 0 ? dispatchEpoch - (continueEpoch + 1) : 0;
181
+ if (redispatchCount >= providerFailureThreshold || epochOverhead >= 3) {
182
+ crossings.push({
183
+ code: "sol-stale-wrong-run-telemetry",
184
+ message: `SOL evidence suggests stale or wrong-run telemetry: ` +
185
+ `redispatched=${redispatchCount}, epoch_overhead=${epochOverhead}. ` +
186
+ `This pattern is consistent with the POL-509 wrong-run failure mode.`,
187
+ evidenceRefs: evidencePaths,
188
+ });
189
+ }
190
+ }
191
+ // 6. Validation failures
192
+ const validationFailedWorkers = Object.values(scoreReport.workers).filter((w) => w.validation?.score !== null && w.validation?.score === 0);
193
+ if (validationFailedWorkers.length >= validationFailureThreshold) {
194
+ crossings.push({
195
+ code: "sol-validation-failures",
196
+ message: `SOL evidence shows ${validationFailedWorkers.length} worker(s) with validation failures ` +
197
+ `(threshold: ${validationFailureThreshold}).`,
198
+ evidenceRefs: evidencePaths,
199
+ });
200
+ }
201
+ return crossings;
202
+ }
203
+ /**
204
+ * Evaluate SOL score thresholds and, when policy enables it, append
205
+ * run-health symptoms for each crossing.
206
+ *
207
+ * Advisory only by default. To activate run-health writes:
208
+ * `sol.thresholds.enabled = true` AND one of:
209
+ * `sol.thresholds.policy.createRunHealthReport = true`
210
+ * `sol.thresholds.policy.requireMedic = true`
211
+ *
212
+ * Never throws — threshold evaluation must not block the run.
213
+ */
214
+ function evaluateSolThresholds(params) {
215
+ const { runId, clusterId, scoreReport, evidencePaths = [], thresholdsConfig, repoRoot } = params;
216
+ const crossings = detectSolThresholdCrossings(scoreReport, thresholdsConfig, evidencePaths);
217
+ const policyEnabled = thresholdsConfig.enabled === true;
218
+ const createReport = thresholdsConfig.policy?.createRunHealthReport === true;
219
+ const requireMedic = thresholdsConfig.policy?.requireMedic === true;
220
+ const shouldWrite = policyEnabled && (createReport || requireMedic);
221
+ if (!shouldWrite || crossings.length === 0) {
222
+ return { crossings, symptomsAppended: 0, medicRequired: false };
223
+ }
224
+ let symptomsAppended = 0;
225
+ let medicDecisionWritten = false;
226
+ try {
227
+ for (const crossing of crossings) {
228
+ upsertSolSymptom(runId, clusterId, makeSolSymptom(crossing), repoRoot);
229
+ symptomsAppended++;
230
+ }
231
+ // If requireMedic is true, set medic_consult to pending so finalize gate fires.
232
+ if (requireMedic && crossings.length > 0) {
233
+ (0, index_js_1.markMedicDecision)(runId, { status: "pending" }, repoRoot);
234
+ medicDecisionWritten = true;
235
+ }
236
+ }
237
+ catch {
238
+ // Threshold evaluation must never block the run.
239
+ // ponytail: surface write errors to telemetry in a future pass
240
+ }
241
+ return {
242
+ crossings,
243
+ symptomsAppended,
244
+ medicRequired: medicDecisionWritten,
245
+ };
246
+ }
package/dist/cli/medic.js CHANGED
@@ -5,6 +5,10 @@ const commander_1 = require("commander");
5
5
  const node_path_1 = require("node:path");
6
6
  const node_fs_1 = require("node:fs");
7
7
  const chart_id_js_1 = require("../medic/chart-id.js");
8
+ const loader_js_1 = require("../config/loader.js");
9
+ const terminal_cli_js_1 = require("../loop/adapters/terminal-cli.js");
10
+ const run_health_consult_js_1 = require("../medic/run-health-consult.js");
11
+ const treatment_packets_js_1 = require("../medic/treatment-packets.js");
8
12
  function createMedicCommand(options = {}) {
9
13
  const repoRootDefault = options.repoRoot ?? (0, node_path_1.resolve)(process.cwd());
10
14
  const medic = new commander_1.Command("medic")
@@ -34,6 +38,67 @@ function createMedicCommand(options = {}) {
34
38
  process.exit(1);
35
39
  }
36
40
  }));
41
+ medic
42
+ .command("run-health-consult")
43
+ .description("Run a Medic run-health consult from a packet file")
44
+ .requiredOption("--packet-file <path>", "Path to MedicRunHealthPacket JSON")
45
+ .option("-r, --repo-root <path>", "Repository root", repoRootDefault)
46
+ .action(async (cmdOptions) => {
47
+ const repoRoot = cmdOptions.repoRoot;
48
+ let packet;
49
+ try {
50
+ packet = JSON.parse((0, node_fs_1.readFileSync)(cmdOptions.packetFile, "utf-8"));
51
+ }
52
+ catch (err) {
53
+ process.stderr.write(`medic run-health-consult error: cannot read packet: ${err instanceof Error ? err.message : String(err)}\n`);
54
+ process.exit(1);
55
+ return;
56
+ }
57
+ if (packet.role !== "medic-run-health") {
58
+ process.stderr.write(`medic run-health-consult error: packet role must be \"medic-run-health\", got \"${packet.role}\"\n`);
59
+ process.exit(1);
60
+ return;
61
+ }
62
+ const config = (0, loader_js_1.loadConfig)(repoRoot);
63
+ const adapter = new terminal_cli_js_1.TerminalCliAdapter(config.execution);
64
+ // Use shared provider resolution logic: prefer rotation, fall back to first provider
65
+ const providerName = (config.execution.rotation && config.execution.rotation.length > 0)
66
+ ? config.execution.rotation[0]
67
+ : Object.keys(config.execution.providers ?? {})[0] ?? "terminal-cli";
68
+ // Get current branch from git
69
+ let branch = "main";
70
+ try {
71
+ const { execFileSync } = require("node:child_process");
72
+ branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
73
+ cwd: repoRoot,
74
+ encoding: "utf-8",
75
+ }).trim();
76
+ }
77
+ catch {
78
+ // Fall back to main if git fails
79
+ }
80
+ try {
81
+ const result = await (0, run_health_consult_js_1.runMedicRunHealthConsult)({
82
+ packet,
83
+ repoRoot,
84
+ stateFile: packet.cluster_state_path,
85
+ telemetryFile: packet.telemetry_path,
86
+ branch,
87
+ dryRun: false,
88
+ dispatchTreatmentWorkerFn: (input) => (0, treatment_packets_js_1.dispatchTreatmentWorker)({
89
+ ...input,
90
+ repoRoot,
91
+ dispatch: (workerPacket) => adapter.dispatch(workerPacket, { provider: providerName }),
92
+ }),
93
+ });
94
+ (0, node_fs_1.writeFileSync)(packet.result_path, JSON.stringify(result, null, 2), "utf-8");
95
+ process.stdout.write(`Medic run-health consult result written to ${packet.result_path}\n`);
96
+ }
97
+ catch (err) {
98
+ process.stderr.write(`medic run-health-consult error: ${err instanceof Error ? err.message : String(err)}\n`);
99
+ process.exit(1);
100
+ }
101
+ });
37
102
  return medic;
38
103
  }
39
104
  function createChart(options) {
@@ -55,6 +55,9 @@ exports.DEFAULT_CONFIG = {
55
55
  requireMapValidation: true,
56
56
  requireSchemaValidation: true,
57
57
  archiveRunSnapshot: true,
58
+ medic: {
59
+ bypassPolicy: "none",
60
+ },
58
61
  },
59
62
  tracker: {
60
63
  lifecyclePolicy: {
@@ -548,6 +548,20 @@ function validateConfig(config) {
548
548
  result.errors.push("finalize.archiveRunSnapshot must be a boolean");
549
549
  }
550
550
  }
551
+ if ("medic" in config.finalize && config.finalize.medic !== undefined) {
552
+ if (!isPlainObject(config.finalize.medic)) {
553
+ result.valid = false;
554
+ result.errors.push("finalize.medic must be an object");
555
+ }
556
+ else {
557
+ if ("bypassPolicy" in config.finalize.medic && config.finalize.medic.bypassPolicy !== undefined) {
558
+ if (!isString(config.finalize.medic.bypassPolicy) || !["none", "cli"].includes(config.finalize.medic.bypassPolicy)) {
559
+ result.valid = false;
560
+ result.errors.push('finalize.medic.bypassPolicy must be one of "none" or "cli"');
561
+ }
562
+ }
563
+ }
564
+ }
551
565
  }
552
566
  }
553
567
  // tracker
@@ -1197,6 +1211,128 @@ function validateConfig(config) {
1197
1211
  }
1198
1212
  }
1199
1213
  }
1214
+ // run_health
1215
+ if ("run_health" in config && config.run_health !== undefined) {
1216
+ if (!isPlainObject(config.run_health)) {
1217
+ result.valid = false;
1218
+ result.errors.push("run_health must be an object");
1219
+ }
1220
+ else {
1221
+ if ("foreman_symptoms" in config.run_health && config.run_health.foreman_symptoms !== undefined) {
1222
+ if (!isPlainObject(config.run_health.foreman_symptoms)) {
1223
+ result.valid = false;
1224
+ result.errors.push("run_health.foreman_symptoms must be an object");
1225
+ }
1226
+ else {
1227
+ if ("enabled" in config.run_health.foreman_symptoms && config.run_health.foreman_symptoms.enabled !== undefined) {
1228
+ if (!isBoolean(config.run_health.foreman_symptoms.enabled)) {
1229
+ result.valid = false;
1230
+ result.errors.push("run_health.foreman_symptoms.enabled must be a boolean");
1231
+ }
1232
+ }
1233
+ }
1234
+ }
1235
+ }
1236
+ }
1237
+ // sol
1238
+ if ("sol" in config && config.sol !== undefined) {
1239
+ if (!isPlainObject(config.sol)) {
1240
+ result.valid = false;
1241
+ result.errors.push("sol must be an object");
1242
+ }
1243
+ else {
1244
+ if ("history" in config.sol && config.sol.history !== undefined) {
1245
+ if (!isPlainObject(config.sol.history)) {
1246
+ result.valid = false;
1247
+ result.errors.push("sol.history must be an object");
1248
+ }
1249
+ else {
1250
+ if ("enabled" in config.sol.history && config.sol.history.enabled !== undefined) {
1251
+ if (!isBoolean(config.sol.history.enabled)) {
1252
+ result.valid = false;
1253
+ result.errors.push("sol.history.enabled must be a boolean");
1254
+ }
1255
+ }
1256
+ if ("path" in config.sol.history && config.sol.history.path !== undefined) {
1257
+ if (!isString(config.sol.history.path)) {
1258
+ result.valid = false;
1259
+ result.errors.push("sol.history.path must be a string");
1260
+ }
1261
+ }
1262
+ }
1263
+ }
1264
+ if ("thresholds" in config.sol && config.sol.thresholds !== undefined) {
1265
+ if (!isPlainObject(config.sol.thresholds)) {
1266
+ result.valid = false;
1267
+ result.errors.push("sol.thresholds must be an object");
1268
+ }
1269
+ else {
1270
+ if ("enabled" in config.sol.thresholds && config.sol.thresholds.enabled !== undefined) {
1271
+ if (!isBoolean(config.sol.thresholds.enabled)) {
1272
+ result.valid = false;
1273
+ result.errors.push("sol.thresholds.enabled must be a boolean");
1274
+ }
1275
+ }
1276
+ if ("policy" in config.sol.thresholds && config.sol.thresholds.policy !== undefined) {
1277
+ if (!isPlainObject(config.sol.thresholds.policy)) {
1278
+ result.valid = false;
1279
+ result.errors.push("sol.thresholds.policy must be an object");
1280
+ }
1281
+ else {
1282
+ if ("createRunHealthReport" in config.sol.thresholds.policy && config.sol.thresholds.policy.createRunHealthReport !== undefined) {
1283
+ if (!isBoolean(config.sol.thresholds.policy.createRunHealthReport)) {
1284
+ result.valid = false;
1285
+ result.errors.push("sol.thresholds.policy.createRunHealthReport must be a boolean");
1286
+ }
1287
+ }
1288
+ if ("requireMedic" in config.sol.thresholds.policy && config.sol.thresholds.policy.requireMedic !== undefined) {
1289
+ if (!isBoolean(config.sol.thresholds.policy.requireMedic)) {
1290
+ result.valid = false;
1291
+ result.errors.push("sol.thresholds.policy.requireMedic must be a boolean");
1292
+ }
1293
+ }
1294
+ }
1295
+ }
1296
+ if ("low_composite_score" in config.sol.thresholds && config.sol.thresholds.low_composite_score !== undefined) {
1297
+ if (!isNumber(config.sol.thresholds.low_composite_score) || !inRange(config.sol.thresholds.low_composite_score, 0, 1)) {
1298
+ result.valid = false;
1299
+ result.errors.push("sol.thresholds.low_composite_score must be a number between 0 and 1");
1300
+ }
1301
+ }
1302
+ if ("qc_repair_loop_failure_statuses" in config.sol.thresholds && config.sol.thresholds.qc_repair_loop_failure_statuses !== undefined) {
1303
+ if (!isStringArray(config.sol.thresholds.qc_repair_loop_failure_statuses)) {
1304
+ result.valid = false;
1305
+ result.errors.push("sol.thresholds.qc_repair_loop_failure_statuses must be an array of strings");
1306
+ }
1307
+ }
1308
+ if ("repeated_provider_failures" in config.sol.thresholds && config.sol.thresholds.repeated_provider_failures !== undefined) {
1309
+ if (!isPositiveInteger(config.sol.thresholds.repeated_provider_failures)) {
1310
+ result.valid = false;
1311
+ result.errors.push("sol.thresholds.repeated_provider_failures must be a positive integer");
1312
+ }
1313
+ }
1314
+ if ("foreman_intervention_count" in config.sol.thresholds && config.sol.thresholds.foreman_intervention_count !== undefined) {
1315
+ if (!isNonNegativeInteger(config.sol.thresholds.foreman_intervention_count)) {
1316
+ result.valid = false;
1317
+ result.errors.push("sol.thresholds.foreman_intervention_count must be a non-negative integer");
1318
+ }
1319
+ }
1320
+ if ("stale_wrong_run_telemetry" in config.sol.thresholds && config.sol.thresholds.stale_wrong_run_telemetry !== undefined) {
1321
+ if (!isBoolean(config.sol.thresholds.stale_wrong_run_telemetry)) {
1322
+ result.valid = false;
1323
+ result.errors.push("sol.thresholds.stale_wrong_run_telemetry must be a boolean");
1324
+ }
1325
+ }
1326
+ if ("validation_failures" in config.sol.thresholds && config.sol.thresholds.validation_failures !== undefined) {
1327
+ if (!isNonNegativeInteger(config.sol.thresholds.validation_failures)) {
1328
+ result.valid = false;
1329
+ result.errors.push("sol.thresholds.validation_failures must be a non-negative integer");
1330
+ }
1331
+ }
1332
+ }
1333
+ }
1334
+ }
1335
+ }
1200
1336
  // unknown top-level fields -> warnings
1201
1337
  const knownKeys = new Set([
1202
1338
  "version",
@@ -1213,6 +1349,11 @@ function validateConfig(config) {
1213
1349
  "budget",
1214
1350
  "compact",
1215
1351
  "qc",
1352
+ "sol",
1353
+ "run_health",
1354
+ "orchestration",
1355
+ "skill_packet",
1356
+ "simplicity",
1216
1357
  ]);
1217
1358
  for (const key of Object.keys(config)) {
1218
1359
  if (!knownKeys.has(key)) {
@@ -34,6 +34,7 @@ const local_graph_js_1 = require("../tracker/local-graph.js");
34
34
  const index_js_1 = require("../tracker/sync/index.js");
35
35
  const finalize_evidence_js_1 = require("../loop/finalize-evidence.js");
36
36
  const delivery_integrity_js_1 = require("./delivery-integrity.js");
37
+ const medic_gate_js_1 = require("./medic-gate.js");
37
38
  const index_js_2 = require("../qc/index.js");
38
39
  function getBranch(repoRoot) {
39
40
  try {
@@ -368,7 +369,7 @@ async function runQcGate(options) {
368
369
  process.exit(1);
369
370
  }
370
371
  async function runFinalize(options) {
371
- const { repoRoot, stateFile, dryRun, skipDelivery, skipLibrarian } = options;
372
+ const { repoRoot, stateFile, dryRun, skipDelivery, skipLibrarian, bypassMedicReason } = options;
372
373
  const config = (0, loader_js_1.loadConfig)(repoRoot);
373
374
  // Step 1: polaris map update --changed
374
375
  console.log("[1/14] Updating map..."); // Step count updated
@@ -580,6 +581,29 @@ async function runFinalize(options) {
580
581
  process.stderr.write(`finalize aborted: authoritative child state mismatch.\n${authChildResult.reason}\n`);
581
582
  process.exit(1);
582
583
  }
584
+ // Step 5.11: Run-health Medic gate
585
+ // After all worker/QC evidence is available and before any final commit, push,
586
+ // PR creation, or tracker update, require a Medic consultation decision for any
587
+ // run that recorded health symptoms.
588
+ {
589
+ console.log("[5.11/14] Checking run-health Medic gate...");
590
+ const bypassPolicy = config.finalize?.medic?.bypassPolicy ?? "none";
591
+ if (dryRun && bypassMedicReason && bypassPolicy === "cli") {
592
+ console.warn(`Dry run: would write Medic bypass to run-health report for run "${state.run_id}" (reason: ${bypassMedicReason}).`);
593
+ }
594
+ const medicBlocker = (0, medic_gate_js_1.validateMedicGate)({
595
+ runId: state.run_id,
596
+ repoRoot,
597
+ bypassReason: bypassMedicReason,
598
+ bypassPolicy,
599
+ dryRun,
600
+ });
601
+ if (medicBlocker) {
602
+ process.stderr.write(`finalize aborted: run-health Medic gate failed.\n${medicBlocker}\n`);
603
+ process.exit(1);
604
+ }
605
+ console.log("[5.11/14] Run-health Medic gate passed.");
606
+ }
583
607
  // Step 6: Tracker Reconciliation
584
608
  // LinearAdapter is sync-in only; only McpBridgeAdapter supports full reconciliation.
585
609
  const trackerType = config.tracker?.adapter;
@@ -715,11 +739,19 @@ function createFinalizeCommand(handlers = {}) {
715
739
  .option("--dry-run", "non-mutating preview: validate and generate report without committing or pushing")
716
740
  .option("--skip-delivery", "perform local finalize steps only; skip push/PR/Linear/archive")
717
741
  .option("--skip-librarian", "skip the Closeout Librarian gate (backward compatibility only)")
742
+ .option("--bypass-medic <reason>", "bypass the run-health Medic gate (requires finalize.medic.bypassPolicy=cli)")
718
743
  .action((options) => {
719
744
  const repoRoot = options.repoRoot;
720
745
  const stateFile = options.stateFile ??
721
746
  (0, node_path_1.join)(repoRoot, ".taskchain_artifacts", "polaris-run", "current-state.json");
722
- finalizeHandler({ repoRoot, stateFile, dryRun: options.dryRun, skipDelivery: options.skipDelivery, skipLibrarian: options.skipLibrarian })
747
+ finalizeHandler({
748
+ repoRoot,
749
+ stateFile,
750
+ dryRun: options.dryRun,
751
+ skipDelivery: options.skipDelivery,
752
+ skipLibrarian: options.skipLibrarian,
753
+ bypassMedicReason: options.bypassMedic,
754
+ })
723
755
  .catch((err) => {
724
756
  process.stderr.write(`finalize error: ${err instanceof Error ? err.message : String(err)}\n`);
725
757
  process.exit(1);
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateMedicGate = validateMedicGate;
4
+ const index_js_1 = require("../run-health/index.js");
5
+ /**
6
+ * Validates that a run with a run-health report either has a Medic decision or
7
+ * an explicit, allowed policy bypass. Returns `null` when finalize may proceed;
8
+ * returns a human-readable blocker string otherwise.
9
+ *
10
+ * Fails closed: a report that exists but lacks a Medic decision/bypass blocks
11
+ * final commit, push, PR creation, and tracker update.
12
+ */
13
+ function validateMedicGate(options) {
14
+ const { runId, repoRoot, bypassReason, bypassPolicy = "none", dryRun = false } = options;
15
+ const reportPath = (0, index_js_1.getRunHealthReportPath)(runId, repoRoot);
16
+ const report = (0, index_js_1.readRunHealthReport)(runId, repoRoot);
17
+ // No report means no symptoms were recorded — gate does not apply.
18
+ if (!report)
19
+ return null;
20
+ if ((0, index_js_1.isMedicGateSatisfied)(report))
21
+ return null;
22
+ if (bypassReason) {
23
+ if (bypassPolicy !== "cli") {
24
+ return (`Run-health report at ${reportPath} requires Medic consultation, but a CLI bypass is not allowed ` +
25
+ `by finalize.medic.bypassPolicy (current: "${bypassPolicy}").`);
26
+ }
27
+ if (!dryRun) {
28
+ (0, index_js_1.markBypassed)(runId, {
29
+ reason: bypassReason,
30
+ bypassed_by: process.env.USER ?? process.env.LOGNAME ?? "operator",
31
+ bypassed_at: new Date().toISOString(),
32
+ }, repoRoot);
33
+ }
34
+ return null;
35
+ }
36
+ const bypassHint = bypassPolicy === "cli"
37
+ ? `To bypass this gate, use --bypass-medic "<reason>".`
38
+ : `Bypass is not enabled by finalize.medic.bypassPolicy (current: "${bypassPolicy}").`;
39
+ return (`Run-health report at ${reportPath} has recorded symptoms and requires a Medic consultation decision ` +
40
+ `(medic_consult.status "resolved" or "bypassed", or an explicit policy_bypass) before finalize can commit, push, ` +
41
+ `create a PR, or update the tracker.\n${bypassHint}`);
42
+ }