@lsctech/polaris 0.4.9 → 0.5.2

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 (42) hide show
  1. package/dist/autoresearch/gates.js +47 -0
  2. package/dist/autoresearch/proposal.js +183 -1
  3. package/dist/autoresearch/score.js +276 -0
  4. package/dist/cli/init.js +17 -0
  5. package/dist/cluster-state/store.js +70 -1
  6. package/dist/config/defaults.js +25 -0
  7. package/dist/config/validator.js +390 -0
  8. package/dist/finalize/artifact-policy.js +3 -1
  9. package/dist/finalize/index.js +73 -17
  10. package/dist/finalize/linear.js +45 -0
  11. package/dist/finalize/run-report.js +68 -12
  12. package/dist/loop/adapters/agent-subtask.js +19 -0
  13. package/dist/loop/adapters/cli-subtask-bridge.js +0 -1
  14. package/dist/loop/adapters/terminal-cli.js +194 -14
  15. package/dist/loop/checkpoint.js +40 -0
  16. package/dist/loop/dispatch-boundary.js +29 -0
  17. package/dist/loop/dispatch.js +193 -22
  18. package/dist/loop/orphan-recovery.js +56 -0
  19. package/dist/loop/parent.js +197 -22
  20. package/dist/loop/router/engine.js +229 -0
  21. package/dist/loop/router/index.js +5 -0
  22. package/dist/loop/router/types.js +2 -0
  23. package/dist/loop/worker-packet.js +16 -0
  24. package/dist/qc/artifacts.js +117 -0
  25. package/dist/qc/attribution.js +266 -0
  26. package/dist/qc/autofix.js +77 -0
  27. package/dist/qc/index.js +30 -0
  28. package/dist/qc/orchestration.js +150 -0
  29. package/dist/qc/policy.js +70 -0
  30. package/dist/qc/provider.js +28 -0
  31. package/dist/qc/providers/coderabbit.js +214 -0
  32. package/dist/qc/providers/index.js +5 -0
  33. package/dist/qc/registry.js +16 -0
  34. package/dist/qc/routing.js +55 -0
  35. package/dist/qc/runner.js +137 -0
  36. package/dist/qc/schemas.js +110 -0
  37. package/dist/qc/security-category.js +11 -0
  38. package/dist/qc/severity.js +78 -0
  39. package/dist/qc/triggers.js +92 -0
  40. package/dist/qc/types.js +9 -0
  41. package/dist/runtime/scheduling/child-selector.js +81 -0
  42. package/package.json +1 -1
@@ -33,17 +33,54 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.initializeClusterState = exports.writeClusterStateSync = exports.writeClusterState = exports.readClusterStateSync = exports.readClusterState = void 0;
36
+ exports.recordQcRun = exports.initializeClusterState = exports.writeClusterStateSync = exports.writeClusterState = exports.readClusterStateSync = exports.readClusterState = void 0;
37
+ exports.pruneExpiredClaims = pruneExpiredClaims;
37
38
  const fs_1 = require("fs");
38
39
  const path = __importStar(require("path"));
39
40
  const local_graph_1 = require("../tracker/local-graph");
41
+ const artifacts_js_1 = require("../qc/artifacts.js");
40
42
  const getClusterStatePath = (clusterId, repoRoot) => {
41
43
  return path.join(repoRoot || process.cwd(), '.polaris', 'clusters', clusterId, 'cluster-state.json');
42
44
  };
43
45
  const normalizeClusterState = (state) => ({
44
46
  ...state,
45
47
  tracker_mutations: state.tracker_mutations ?? {},
48
+ qc_runs: state.qc_runs ?? {},
46
49
  });
50
+ function pruneExpiredClaims(state, now = new Date()) {
51
+ const nowMs = now.getTime();
52
+ const expiredChildIds = [];
53
+ const nextClaimMetadata = {};
54
+ for (const [childId, claim] of Object.entries(state.claim_metadata ?? {})) {
55
+ const expiresAtMs = new Date(claim.expires_at).getTime();
56
+ const active = Number.isFinite(expiresAtMs) && expiresAtMs > nowMs;
57
+ if (active) {
58
+ nextClaimMetadata[childId] = claim;
59
+ continue;
60
+ }
61
+ expiredChildIds.push(childId);
62
+ }
63
+ if (expiredChildIds.length === 0) {
64
+ return { state, expiredChildIds };
65
+ }
66
+ const expiredSet = new Set(expiredChildIds);
67
+ const recoverableStatuses = new Set(["claimed", "dispatched", "running"]);
68
+ const child_states = state.child_states.map((child) => {
69
+ if (!expiredSet.has(child.id) || !recoverableStatuses.has(child.status)) {
70
+ return child;
71
+ }
72
+ return { ...child, status: "ready" };
73
+ });
74
+ return {
75
+ state: {
76
+ ...state,
77
+ state_generation: state.state_generation + 1,
78
+ claim_metadata: nextClaimMetadata,
79
+ child_states,
80
+ },
81
+ expiredChildIds,
82
+ };
83
+ }
47
84
  const readClusterState = async (clusterId, repoRoot) => {
48
85
  const filePath = getClusterStatePath(clusterId, repoRoot);
49
86
  try {
@@ -295,8 +332,40 @@ const initializeClusterState = async (clusterId, repoRoot) => {
295
332
  commits: {},
296
333
  tracker_mutations: {},
297
334
  blockers: [],
335
+ qc_runs: {},
298
336
  };
299
337
  await (0, exports.writeClusterState)(clusterId, initialState, repoRoot);
300
338
  return initialState;
301
339
  };
302
340
  exports.initializeClusterState = initializeClusterState;
341
+ /**
342
+ * Persist a QC result artifact under the active cluster's evidence surface and
343
+ * record a pointer in the cluster state. This is the only supported way to
344
+ * durably store QC runs; callers must not write QC artifacts directly outside
345
+ * `.polaris/clusters/<cluster-id>/qc/`.
346
+ */
347
+ const recordQcRun = async (clusterId, result, repoRoot) => {
348
+ const artifactPath = (0, artifacts_js_1.writeQcArtifact)(clusterId, result, repoRoot);
349
+ const currentState = await (0, exports.readClusterState)(clusterId, repoRoot);
350
+ if (!currentState) {
351
+ throw new Error(`Cluster ${clusterId} state not found; cannot record QC run ${result.qcRunId}.`);
352
+ }
353
+ const pointer = {
354
+ artifact_path: artifactPath,
355
+ status: result.status,
356
+ provider: result.provider,
357
+ started_at: result.startedAt,
358
+ completed_at: result.completedAt,
359
+ };
360
+ const nextState = {
361
+ ...currentState,
362
+ state_generation: currentState.state_generation + 1,
363
+ qc_runs: {
364
+ ...currentState.qc_runs,
365
+ [result.qcRunId]: pointer,
366
+ },
367
+ };
368
+ await (0, exports.writeClusterState)(clusterId, nextState, repoRoot);
369
+ return { artifactPath, state: nextState };
370
+ };
371
+ exports.recordQcRun = recordQcRun;
@@ -39,6 +39,14 @@ exports.DEFAULT_CONFIG = {
39
39
  rotation: [],
40
40
  allowCrossAgentFallback: false,
41
41
  roles: {},
42
+ routerPolicy: {
43
+ defaultWorkerPool: {
44
+ maxActiveWorkers: 1,
45
+ maxActiveSlots: 1,
46
+ },
47
+ providerRegistry: {},
48
+ allowCrossProviderFallback: false,
49
+ },
42
50
  },
43
51
  finalize: {
44
52
  targetBranch: "main",
@@ -95,4 +103,21 @@ exports.DEFAULT_CONFIG = {
95
103
  auto_deep_analysis: false,
96
104
  allow_cross_provider_delegation: false,
97
105
  },
106
+ qc: {
107
+ enabled: false,
108
+ defaultTrigger: "completed-cluster",
109
+ providers: {},
110
+ severityThresholds: {
111
+ block: "high",
112
+ repair: "medium",
113
+ followUp: "low",
114
+ },
115
+ autoFix: "disabled",
116
+ repairRouting: "route",
117
+ artifactRetention: {
118
+ retainRawOutput: false,
119
+ maxRuns: 10,
120
+ },
121
+ routes: {},
122
+ },
98
123
  };
@@ -7,6 +7,9 @@ function isPlainObject(value) {
7
7
  function isNumber(value) {
8
8
  return typeof value === "number" && !Number.isNaN(value);
9
9
  }
10
+ function isPositiveInteger(value) {
11
+ return Number.isInteger(value) && Number(value) > 0;
12
+ }
10
13
  function isString(value) {
11
14
  return typeof value === "string";
12
15
  }
@@ -44,6 +47,29 @@ const SUPPORTED_EXECUTION_ADAPTERS = [
44
47
  "remote-worker",
45
48
  "cross-agent",
46
49
  ];
50
+ const SUPPORTED_ROUTER_CAPABILITIES = [
51
+ "orchestration",
52
+ "analysis",
53
+ "implementation",
54
+ "repair",
55
+ "docs",
56
+ "finalization",
57
+ ];
58
+ const SUPPORTED_ROUTER_TASK_TYPES = [
59
+ "startup",
60
+ "analyze",
61
+ "impl",
62
+ "repair",
63
+ "docs",
64
+ "finalize",
65
+ ];
66
+ const SUPPORTED_ROUTER_TRUST_TIERS = ["sandbox", "standard", "trusted"];
67
+ const SUPPORTED_ROUTER_COST_TIERS = ["low", "medium", "high"];
68
+ const SUPPORTED_ROUTER_QUOTA_POLICIES = [
69
+ "best-effort",
70
+ "rate-limited",
71
+ "reserved",
72
+ ];
47
73
  const SUPPORTED_LIFECYCLE_STATES = [
48
74
  "backlog",
49
75
  "in_progress",
@@ -53,6 +79,33 @@ const SUPPORTED_LIFECYCLE_STATES = [
53
79
  "cancelled",
54
80
  "no_status_change",
55
81
  ];
82
+ const SUPPORTED_QC_TRIGGERS = ["pr", "completed-cluster", "child"];
83
+ const SUPPORTED_QC_SEVERITIES = ["critical", "high", "medium", "low", "info"];
84
+ const SUPPORTED_QC_PROVIDER_MODES = ["local", "pr", "metrics-import"];
85
+ const SUPPORTED_QC_AUTO_FIX_POLICIES = ["disabled", "dry-run", "apply"];
86
+ const SUPPORTED_QC_REPAIR_ROUTING_POLICIES = ["block", "route", "follow-up", "log"];
87
+ const SUPPORTED_QC_PROVIDER_CAPABILITIES = [
88
+ "diff-review",
89
+ "pr-review",
90
+ "result-parsing",
91
+ "auto-fix",
92
+ "metrics-import",
93
+ ];
94
+ const SEVERITY_ORDER = ["info", "low", "medium", "high", "critical"];
95
+ function severityIndex(severity) {
96
+ return SEVERITY_ORDER.indexOf(severity);
97
+ }
98
+ function hasEligibleQcAutoFixProvider(providers) {
99
+ if (!isPlainObject(providers))
100
+ return false;
101
+ return Object.values(providers).some((providerConfig) => {
102
+ if (!isPlainObject(providerConfig))
103
+ return false;
104
+ const capabilities = providerConfig.capabilities;
105
+ const eligible = providerConfig.autoFixEligible;
106
+ return eligible === true && Array.isArray(capabilities) && capabilities.includes("auto-fix");
107
+ });
108
+ }
56
109
  function validateConfig(config) {
57
110
  const result = { valid: true, errors: [], warnings: [] };
58
111
  if (!isPlainObject(config)) {
@@ -325,6 +378,126 @@ function validateConfig(config) {
325
378
  }
326
379
  }
327
380
  }
381
+ if ("routerPolicy" in config.execution && config.execution.routerPolicy !== undefined) {
382
+ if (!isPlainObject(config.execution.routerPolicy)) {
383
+ result.valid = false;
384
+ result.errors.push("execution.routerPolicy must be a plain object");
385
+ }
386
+ else {
387
+ const routerPolicy = config.execution.routerPolicy;
388
+ const providerKeys = isPlainObject(config.execution.providers)
389
+ ? new Set(Object.keys(config.execution.providers))
390
+ : null;
391
+ if ("allowCrossProviderFallback" in routerPolicy &&
392
+ routerPolicy.allowCrossProviderFallback !== undefined &&
393
+ !isBoolean(routerPolicy.allowCrossProviderFallback)) {
394
+ result.valid = false;
395
+ result.errors.push("execution.routerPolicy.allowCrossProviderFallback must be a boolean");
396
+ }
397
+ if (isBoolean(config.execution.allowCrossAgentFallback) &&
398
+ isBoolean(routerPolicy.allowCrossProviderFallback) &&
399
+ config.execution.allowCrossAgentFallback !== routerPolicy.allowCrossProviderFallback) {
400
+ result.valid = false;
401
+ result.errors.push("execution fallback policy is ambiguous: allowCrossAgentFallback conflicts with execution.routerPolicy.allowCrossProviderFallback");
402
+ }
403
+ if ("defaultWorkerPool" in routerPolicy && routerPolicy.defaultWorkerPool !== undefined) {
404
+ if (!isPlainObject(routerPolicy.defaultWorkerPool)) {
405
+ result.valid = false;
406
+ result.errors.push("execution.routerPolicy.defaultWorkerPool must be a plain object");
407
+ }
408
+ else {
409
+ if ("maxActiveWorkers" in routerPolicy.defaultWorkerPool &&
410
+ routerPolicy.defaultWorkerPool.maxActiveWorkers !== undefined &&
411
+ !isPositiveInteger(routerPolicy.defaultWorkerPool.maxActiveWorkers)) {
412
+ result.valid = false;
413
+ result.errors.push("execution.routerPolicy.defaultWorkerPool.maxActiveWorkers must be a positive integer");
414
+ }
415
+ if ("maxActiveSlots" in routerPolicy.defaultWorkerPool &&
416
+ routerPolicy.defaultWorkerPool.maxActiveSlots !== undefined &&
417
+ !isPositiveInteger(routerPolicy.defaultWorkerPool.maxActiveSlots)) {
418
+ result.valid = false;
419
+ result.errors.push("execution.routerPolicy.defaultWorkerPool.maxActiveSlots must be a positive integer");
420
+ }
421
+ }
422
+ }
423
+ if ("providerRegistry" in routerPolicy && routerPolicy.providerRegistry !== undefined) {
424
+ if (!isPlainObject(routerPolicy.providerRegistry)) {
425
+ result.valid = false;
426
+ result.errors.push("execution.routerPolicy.providerRegistry must be a plain object");
427
+ }
428
+ else {
429
+ for (const [providerName, providerPolicy] of Object.entries(routerPolicy.providerRegistry)) {
430
+ if (providerKeys && !providerKeys.has(providerName)) {
431
+ result.valid = false;
432
+ result.errors.push(`execution.routerPolicy.providerRegistry contains unknown provider: ${providerName}`);
433
+ }
434
+ if (!isPlainObject(providerPolicy)) {
435
+ result.valid = false;
436
+ result.errors.push(`execution.routerPolicy.providerRegistry.${providerName} must be a plain object`);
437
+ continue;
438
+ }
439
+ if ("eligibleRoles" in providerPolicy && providerPolicy.eligibleRoles !== undefined) {
440
+ if (!Array.isArray(providerPolicy.eligibleRoles) ||
441
+ !providerPolicy.eligibleRoles.every((role) => isString(role) &&
442
+ SUPPORTED_EXECUTION_ROLES.includes(role))) {
443
+ result.valid = false;
444
+ result.errors.push(`execution.routerPolicy.providerRegistry.${providerName}.eligibleRoles must contain only supported execution roles`);
445
+ }
446
+ }
447
+ if ("capabilities" in providerPolicy && providerPolicy.capabilities !== undefined) {
448
+ if (!Array.isArray(providerPolicy.capabilities) ||
449
+ !providerPolicy.capabilities.every((capability) => isString(capability) &&
450
+ SUPPORTED_ROUTER_CAPABILITIES.includes(capability))) {
451
+ result.valid = false;
452
+ result.errors.push(`execution.routerPolicy.providerRegistry.${providerName}.capabilities must contain only: orchestration, analysis, implementation, repair, docs, finalization`);
453
+ }
454
+ }
455
+ if ("taskTypes" in providerPolicy && providerPolicy.taskTypes !== undefined) {
456
+ if (!Array.isArray(providerPolicy.taskTypes) ||
457
+ !providerPolicy.taskTypes.every((taskType) => isString(taskType) &&
458
+ SUPPORTED_ROUTER_TASK_TYPES.includes(taskType))) {
459
+ result.valid = false;
460
+ result.errors.push(`execution.routerPolicy.providerRegistry.${providerName}.taskTypes must contain only: startup, analyze, impl, repair, docs, finalize`);
461
+ }
462
+ }
463
+ if ("trustTier" in providerPolicy && providerPolicy.trustTier !== undefined) {
464
+ if (!isString(providerPolicy.trustTier) ||
465
+ !SUPPORTED_ROUTER_TRUST_TIERS.includes(providerPolicy.trustTier)) {
466
+ result.valid = false;
467
+ result.errors.push(`execution.routerPolicy.providerRegistry.${providerName}.trustTier must be one of: sandbox, standard, trusted`);
468
+ }
469
+ }
470
+ if ("costTier" in providerPolicy && providerPolicy.costTier !== undefined) {
471
+ if (!isString(providerPolicy.costTier) ||
472
+ !SUPPORTED_ROUTER_COST_TIERS.includes(providerPolicy.costTier)) {
473
+ result.valid = false;
474
+ result.errors.push(`execution.routerPolicy.providerRegistry.${providerName}.costTier must be one of: low, medium, high`);
475
+ }
476
+ }
477
+ if ("quotaPolicy" in providerPolicy && providerPolicy.quotaPolicy !== undefined) {
478
+ if (!isString(providerPolicy.quotaPolicy) ||
479
+ !SUPPORTED_ROUTER_QUOTA_POLICIES.includes(providerPolicy.quotaPolicy)) {
480
+ result.valid = false;
481
+ result.errors.push(`execution.routerPolicy.providerRegistry.${providerName}.quotaPolicy must be one of: best-effort, rate-limited, reserved`);
482
+ }
483
+ }
484
+ if ("fallbackEligible" in providerPolicy &&
485
+ providerPolicy.fallbackEligible !== undefined &&
486
+ !isBoolean(providerPolicy.fallbackEligible)) {
487
+ result.valid = false;
488
+ result.errors.push(`execution.routerPolicy.providerRegistry.${providerName}.fallbackEligible must be a boolean`);
489
+ }
490
+ if ("maxActiveSlots" in providerPolicy &&
491
+ providerPolicy.maxActiveSlots !== undefined &&
492
+ !isPositiveInteger(providerPolicy.maxActiveSlots)) {
493
+ result.valid = false;
494
+ result.errors.push(`execution.routerPolicy.providerRegistry.${providerName}.maxActiveSlots must be a positive integer`);
495
+ }
496
+ }
497
+ }
498
+ }
499
+ }
500
+ }
328
501
  }
329
502
  }
330
503
  // finalize
@@ -651,6 +824,222 @@ function validateConfig(config) {
651
824
  }
652
825
  }
653
826
  }
827
+ // qc
828
+ if ("qc" in config && config.qc !== undefined) {
829
+ if (!isPlainObject(config.qc)) {
830
+ result.valid = false;
831
+ result.errors.push("qc must be an object");
832
+ }
833
+ else {
834
+ if ("enabled" in config.qc && config.qc.enabled !== undefined) {
835
+ if (!isBoolean(config.qc.enabled)) {
836
+ result.valid = false;
837
+ result.errors.push("qc.enabled must be a boolean");
838
+ }
839
+ }
840
+ if ("defaultTrigger" in config.qc && config.qc.defaultTrigger !== undefined) {
841
+ if (!isString(config.qc.defaultTrigger) ||
842
+ !SUPPORTED_QC_TRIGGERS.includes(config.qc.defaultTrigger)) {
843
+ result.valid = false;
844
+ result.errors.push('qc.defaultTrigger must be one of "pr", "completed-cluster", "child"');
845
+ }
846
+ }
847
+ const providers = isPlainObject(config.qc.providers) ? config.qc.providers : null;
848
+ if ("providers" in config.qc && config.qc.providers !== undefined) {
849
+ if (!isPlainObject(config.qc.providers)) {
850
+ result.valid = false;
851
+ result.errors.push("qc.providers must be a plain object");
852
+ }
853
+ else {
854
+ for (const [providerName, providerConfig] of Object.entries(config.qc.providers)) {
855
+ if (!isPlainObject(providerConfig)) {
856
+ result.valid = false;
857
+ result.errors.push(`qc.providers.${providerName} must be a plain object`);
858
+ continue;
859
+ }
860
+ if ("name" in providerConfig && providerConfig.name !== undefined && !isString(providerConfig.name)) {
861
+ result.valid = false;
862
+ result.errors.push(`qc.providers.${providerName}.name must be a string`);
863
+ }
864
+ if ("mode" in providerConfig && providerConfig.mode !== undefined) {
865
+ if (!isString(providerConfig.mode) ||
866
+ !SUPPORTED_QC_PROVIDER_MODES.includes(providerConfig.mode)) {
867
+ result.valid = false;
868
+ result.errors.push(`qc.providers.${providerName}.mode must be one of local, pr, metrics-import`);
869
+ }
870
+ }
871
+ else {
872
+ result.valid = false;
873
+ result.errors.push(`qc.providers.${providerName}.mode is required`);
874
+ }
875
+ if ("capabilities" in providerConfig && providerConfig.capabilities !== undefined) {
876
+ if (!Array.isArray(providerConfig.capabilities) ||
877
+ !providerConfig.capabilities.every((capability) => isString(capability) &&
878
+ SUPPORTED_QC_PROVIDER_CAPABILITIES.includes(capability))) {
879
+ result.valid = false;
880
+ result.errors.push(`qc.providers.${providerName}.capabilities must contain only: diff-review, pr-review, result-parsing, auto-fix, metrics-import`);
881
+ }
882
+ }
883
+ if ("trigger" in providerConfig && providerConfig.trigger !== undefined) {
884
+ if (!isString(providerConfig.trigger) ||
885
+ !SUPPORTED_QC_TRIGGERS.includes(providerConfig.trigger)) {
886
+ result.valid = false;
887
+ result.errors.push(`qc.providers.${providerName}.trigger must be one of pr, completed-cluster, child`);
888
+ }
889
+ }
890
+ if ("autoFixEligible" in providerConfig && providerConfig.autoFixEligible !== undefined) {
891
+ if (!isBoolean(providerConfig.autoFixEligible)) {
892
+ result.valid = false;
893
+ result.errors.push(`qc.providers.${providerName}.autoFixEligible must be a boolean`);
894
+ }
895
+ }
896
+ if ("severityMapping" in providerConfig && providerConfig.severityMapping !== undefined) {
897
+ if (!isPlainObject(providerConfig.severityMapping)) {
898
+ result.valid = false;
899
+ result.errors.push(`qc.providers.${providerName}.severityMapping must be a plain object`);
900
+ }
901
+ else {
902
+ for (const [label, severity] of Object.entries(providerConfig.severityMapping)) {
903
+ if (!isString(severity) ||
904
+ !SUPPORTED_QC_SEVERITIES.includes(severity)) {
905
+ result.valid = false;
906
+ result.errors.push(`qc.providers.${providerName}.severityMapping.${label} must be one of critical, high, medium, low, info`);
907
+ }
908
+ }
909
+ }
910
+ }
911
+ }
912
+ }
913
+ }
914
+ if ("severityThresholds" in config.qc && config.qc.severityThresholds !== undefined) {
915
+ if (!isPlainObject(config.qc.severityThresholds)) {
916
+ result.valid = false;
917
+ result.errors.push("qc.severityThresholds must be a plain object");
918
+ }
919
+ else {
920
+ const thresholds = config.qc.severityThresholds;
921
+ for (const key of ["block", "repair", "followUp"]) {
922
+ if (key in thresholds && thresholds[key] !== undefined) {
923
+ if (!isString(thresholds[key]) ||
924
+ !SUPPORTED_QC_SEVERITIES.includes(thresholds[key])) {
925
+ result.valid = false;
926
+ result.errors.push(`qc.severityThresholds.${key} must be one of critical, high, medium, low, info`);
927
+ }
928
+ }
929
+ }
930
+ if (isString(thresholds.block) &&
931
+ SUPPORTED_QC_SEVERITIES.includes(thresholds.block) &&
932
+ isString(thresholds.repair) &&
933
+ SUPPORTED_QC_SEVERITIES.includes(thresholds.repair) &&
934
+ severityIndex(thresholds.block) < severityIndex(thresholds.repair)) {
935
+ result.valid = false;
936
+ result.errors.push("qc.severityThresholds.repair must be at or below qc.severityThresholds.block severity");
937
+ }
938
+ if (isString(thresholds.repair) &&
939
+ SUPPORTED_QC_SEVERITIES.includes(thresholds.repair) &&
940
+ isString(thresholds.followUp) &&
941
+ SUPPORTED_QC_SEVERITIES.includes(thresholds.followUp) &&
942
+ severityIndex(thresholds.repair) < severityIndex(thresholds.followUp)) {
943
+ result.valid = false;
944
+ result.errors.push("qc.severityThresholds.followUp must be at or below qc.severityThresholds.repair severity");
945
+ }
946
+ }
947
+ }
948
+ if ("autoFix" in config.qc && config.qc.autoFix !== undefined) {
949
+ if (!isString(config.qc.autoFix) ||
950
+ !SUPPORTED_QC_AUTO_FIX_POLICIES.includes(config.qc.autoFix)) {
951
+ result.valid = false;
952
+ result.errors.push('qc.autoFix must be one of "disabled", "dry-run", "apply"');
953
+ }
954
+ }
955
+ if ("repairRouting" in config.qc && config.qc.repairRouting !== undefined) {
956
+ if (!isString(config.qc.repairRouting) ||
957
+ !SUPPORTED_QC_REPAIR_ROUTING_POLICIES.includes(config.qc.repairRouting)) {
958
+ result.valid = false;
959
+ result.errors.push('qc.repairRouting must be one of "block", "route", "follow-up", "log"');
960
+ }
961
+ }
962
+ if ("artifactRetention" in config.qc && config.qc.artifactRetention !== undefined) {
963
+ if (!isPlainObject(config.qc.artifactRetention)) {
964
+ result.valid = false;
965
+ result.errors.push("qc.artifactRetention must be a plain object");
966
+ }
967
+ else {
968
+ const retention = config.qc.artifactRetention;
969
+ if ("retainRawOutput" in retention && retention.retainRawOutput !== undefined) {
970
+ if (!isBoolean(retention.retainRawOutput)) {
971
+ result.valid = false;
972
+ result.errors.push("qc.artifactRetention.retainRawOutput must be a boolean");
973
+ }
974
+ }
975
+ if ("maxRuns" in retention && retention.maxRuns !== undefined) {
976
+ if (!isPositiveInteger(retention.maxRuns)) {
977
+ result.valid = false;
978
+ result.errors.push("qc.artifactRetention.maxRuns must be a positive integer");
979
+ }
980
+ }
981
+ }
982
+ }
983
+ if ("routes" in config.qc && config.qc.routes !== undefined) {
984
+ if (!isPlainObject(config.qc.routes)) {
985
+ result.valid = false;
986
+ result.errors.push("qc.routes must be a plain object");
987
+ }
988
+ else {
989
+ for (const [routeName, routePolicy] of Object.entries(config.qc.routes)) {
990
+ if (!isPlainObject(routePolicy)) {
991
+ result.valid = false;
992
+ result.errors.push(`qc.routes.${routeName} must be a plain object`);
993
+ continue;
994
+ }
995
+ if ("childLevel" in routePolicy && routePolicy.childLevel !== undefined) {
996
+ if (!isBoolean(routePolicy.childLevel)) {
997
+ result.valid = false;
998
+ result.errors.push(`qc.routes.${routeName}.childLevel must be a boolean`);
999
+ }
1000
+ }
1001
+ if ("blockThreshold" in routePolicy && routePolicy.blockThreshold !== undefined) {
1002
+ if (!isString(routePolicy.blockThreshold) ||
1003
+ !SUPPORTED_QC_SEVERITIES.includes(routePolicy.blockThreshold)) {
1004
+ result.valid = false;
1005
+ result.errors.push(`qc.routes.${routeName}.blockThreshold must be one of critical, high, medium, low, info`);
1006
+ }
1007
+ }
1008
+ if ("autoFix" in routePolicy && routePolicy.autoFix !== undefined) {
1009
+ if (!isString(routePolicy.autoFix) ||
1010
+ !SUPPORTED_QC_AUTO_FIX_POLICIES.includes(routePolicy.autoFix)) {
1011
+ result.valid = false;
1012
+ result.errors.push(`qc.routes.${routeName}.autoFix must be one of disabled, dry-run, apply`);
1013
+ }
1014
+ if (routePolicy.autoFix === "apply" && !hasEligibleQcAutoFixProvider(providers)) {
1015
+ result.valid = false;
1016
+ result.errors.push(`qc.routes.${routeName}.autoFix "apply" requires at least one provider with capability "auto-fix" and autoFixEligible true`);
1017
+ }
1018
+ }
1019
+ }
1020
+ }
1021
+ }
1022
+ // Unsafe auto-fix policy combinations
1023
+ const autoFix = isString(config.qc.autoFix)
1024
+ ? config.qc.autoFix
1025
+ : "disabled";
1026
+ const blockSeverity = isPlainObject(config.qc.severityThresholds)
1027
+ ? config.qc.severityThresholds.block
1028
+ : undefined;
1029
+ if (autoFix === "apply") {
1030
+ if (!hasEligibleQcAutoFixProvider(providers)) {
1031
+ result.valid = false;
1032
+ result.errors.push('qc.autoFix "apply" requires at least one provider with capability "auto-fix" and autoFixEligible true');
1033
+ }
1034
+ if (isString(blockSeverity) &&
1035
+ SUPPORTED_QC_SEVERITIES.includes(blockSeverity) &&
1036
+ severityIndex(blockSeverity) <= severityIndex("medium")) {
1037
+ result.valid = false;
1038
+ result.errors.push('qc.autoFix "apply" is unsafe when qc.severityThresholds.block is medium or lower');
1039
+ }
1040
+ }
1041
+ }
1042
+ }
654
1043
  // unknown top-level fields -> warnings
655
1044
  const knownKeys = new Set([
656
1045
  "version",
@@ -666,6 +1055,7 @@ function validateConfig(config) {
666
1055
  "providers",
667
1056
  "budget",
668
1057
  "compact",
1058
+ "qc",
669
1059
  ]);
670
1060
  for (const key of Object.keys(config)) {
671
1061
  if (!knownKeys.has(key)) {
@@ -38,7 +38,8 @@ function isPromotedClusterArtifact(relativePath, activeClusterId) {
38
38
  return (suffix === "clusters.json"
39
39
  || suffix === "cluster-state.json"
40
40
  || suffix.startsWith("packets/")
41
- || suffix.startsWith("results/"));
41
+ || suffix.startsWith("results/")
42
+ || suffix.startsWith("qc/"));
42
43
  }
43
44
  function classifyArtifactPath(filePath, activeClusterId) {
44
45
  const relativePath = normalizeArtifactPath(filePath);
@@ -132,6 +133,7 @@ function getArtifactPromotionPolicy(activeClusterId) {
132
133
  `${activeClusterPrefix}cluster-state.json`,
133
134
  `${activeClusterPrefix}packets/**`,
134
135
  `${activeClusterPrefix}results/**`,
136
+ `${activeClusterPrefix}qc/**`,
135
137
  PROMOTED_RUN_LEDGER,
136
138
  `${PROMOTED_COGNITION_ARCHIVE_PREFIX}**`,
137
139
  `${PROMOTED_MAP_PREFIX}**`,