@lsctech/polaris 0.5.0 → 0.5.3

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.
@@ -79,6 +79,33 @@ const SUPPORTED_LIFECYCLE_STATES = [
79
79
  "cancelled",
80
80
  "no_status_change",
81
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
+ }
82
109
  function validateConfig(config) {
83
110
  const result = { valid: true, errors: [], warnings: [] };
84
111
  if (!isPlainObject(config)) {
@@ -797,6 +824,222 @@ function validateConfig(config) {
797
824
  }
798
825
  }
799
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
+ }
800
1043
  // unknown top-level fields -> warnings
801
1044
  const knownKeys = new Set([
802
1045
  "version",
@@ -812,6 +1055,7 @@ function validateConfig(config) {
812
1055
  "providers",
813
1056
  "budget",
814
1057
  "compact",
1058
+ "qc",
815
1059
  ]);
816
1060
  for (const key of Object.keys(config)) {
817
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}**`,
@@ -29,6 +29,7 @@ const local_graph_js_1 = require("../tracker/local-graph.js");
29
29
  const index_js_1 = require("../tracker/sync/index.js");
30
30
  const finalize_evidence_js_1 = require("../loop/finalize-evidence.js");
31
31
  const delivery_integrity_js_1 = require("./delivery-integrity.js");
32
+ const index_js_2 = require("../qc/index.js");
32
33
  function getBranch(repoRoot) {
33
34
  try {
34
35
  return (0, node_child_process_1.execFileSync)("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
@@ -178,6 +179,36 @@ function checkLibrarianGate(repoRoot, clusterId) {
178
179
  return `Librarian result gate error: ${err instanceof Error ? err.message : String(err)}`;
179
180
  }
180
181
  }
182
+ function resolveQcTelemetryFile(state, repoRoot) {
183
+ const artifactDir = state.artifact_dir ?? (0, node_path_1.join)(repoRoot, ".taskchain_artifacts", "polaris-run");
184
+ return (0, node_path_1.join)(artifactDir, "runs", state.run_id, "telemetry.jsonl");
185
+ }
186
+ async function runQcGate(options) {
187
+ const { config, state, repoRoot, branch, trigger, prUrl, stepLabel } = options;
188
+ const registry = (0, index_js_2.createDefaultQcRegistry)();
189
+ const result = await (0, index_js_2.runQcAtTrigger)({
190
+ config,
191
+ registry,
192
+ trigger,
193
+ prUrl,
194
+ repoRoot,
195
+ runId: state.run_id,
196
+ clusterId: state.cluster_id,
197
+ branch,
198
+ telemetryFile: resolveQcTelemetryFile(state, repoRoot),
199
+ state,
200
+ });
201
+ if (result.action === "pass") {
202
+ console.log(`${stepLabel} QC ${trigger} passed: ${result.summary}`);
203
+ return;
204
+ }
205
+ if (result.action === "follow-up") {
206
+ console.warn(`${stepLabel} QC ${trigger} produced follow-up work: ${result.summary}`);
207
+ return;
208
+ }
209
+ process.stderr.write(`${stepLabel} QC ${trigger} blocked finalize: ${result.summary}\n`);
210
+ process.exit(1);
211
+ }
181
212
  async function runFinalize(options) {
182
213
  const { repoRoot, stateFile, dryRun, skipDelivery, skipLibrarian } = options;
183
214
  const config = (0, loader_js_1.loadConfig)(repoRoot);
@@ -356,6 +387,16 @@ async function runFinalize(options) {
356
387
  }
357
388
  }
358
389
  }
390
+ // Step 5.8: Completed-cluster QC trigger (when configured)
391
+ // Runs after all authoritative gates and before final commit/delivery.
392
+ await runQcGate({
393
+ config: config.qc,
394
+ state,
395
+ repoRoot,
396
+ branch,
397
+ trigger: "completed-cluster",
398
+ stepLabel: "[5.8/14]",
399
+ });
359
400
  // Step 6: Tracker Reconciliation
360
401
  // LinearAdapter is sync-in only; only McpBridgeAdapter supports full reconciliation.
361
402
  const trackerType = config.tracker?.adapter;
@@ -392,28 +433,32 @@ async function runFinalize(options) {
392
433
  else {
393
434
  console.warn(`[6/14] Unknown tracker adapter '${trackerType}' — skipping reconciliation.`);
394
435
  }
395
- // Step 7: Single final commit: source changes + durable Polaris artifacts
396
- console.log("[7/14] Committing durable Polaris state + map..."); // Step count updated
436
+ // Step 7: Closeout Librarian gate (pre-commit preflight)
437
+ // Must run before the finalize commit so that a missing or invalid librarian
438
+ // result aborts before any mutating git I/O.
439
+ if (!skipDelivery) {
440
+ if (!skipLibrarian) {
441
+ console.log("[7/14] Checking Closeout Librarian gate...");
442
+ const librarianBlocker = checkLibrarianGate(repoRoot, state.cluster_id);
443
+ if (librarianBlocker) {
444
+ process.stderr.write(`finalize aborted: Closeout Librarian gate failed.\n${librarianBlocker}\n`);
445
+ process.exit(1);
446
+ }
447
+ console.log("[7/14] Closeout Librarian gate passed.");
448
+ }
449
+ else {
450
+ console.log("[7/14] Closeout Librarian gate skipped (--skip-librarian).");
451
+ }
452
+ }
453
+ // Step 8: Single final commit: source changes + durable Polaris artifacts
454
+ console.log("[8/14] Committing durable Polaris state + map..."); // Step count updated
397
455
  const resolvedStateFile = (0, node_path_1.resolve)(stateFile);
398
456
  (0, _06_commit_js_1.stepCommit)(repoRoot, state, resolvedStateFile, reportPath);
399
457
  if (skipDelivery) {
400
- console.log("[8–14/14] Delivery skipped (--skip-delivery).");
401
- console.log("polaris finalize steps 1–7 complete.");
458
+ console.log("[9–14/14] Delivery skipped (--skip-delivery).");
459
+ console.log("polaris finalize steps 1–8 complete.");
402
460
  return;
403
461
  }
404
- // Step 8: Closeout Librarian gate
405
- if (!skipLibrarian) {
406
- console.log("[8/14] Checking Closeout Librarian gate...");
407
- const librarianBlocker = checkLibrarianGate(repoRoot, state.cluster_id);
408
- if (librarianBlocker) {
409
- process.stderr.write(`finalize aborted: Closeout Librarian gate failed.\n${librarianBlocker}\n`);
410
- process.exit(1);
411
- }
412
- console.log("[8/14] Closeout Librarian gate passed.");
413
- }
414
- else {
415
- console.log("[8/14] Closeout Librarian gate skipped (--skip-librarian).");
416
- }
417
462
  // Step 9: git push
418
463
  console.log("[9/14] Pushing branch...");
419
464
  (0, _07_push_js_1.stepPush)(repoRoot, branch);
@@ -421,6 +466,17 @@ async function runFinalize(options) {
421
466
  const prDraft = config.finalize?.prDraft ?? true;
422
467
  console.log("[10/14] Creating draft PR...");
423
468
  const prUrl = (0, _08_create_pr_js_1.stepCreatePr)(repoRoot, branch, state, prDraft);
469
+ // Step 10.5: PR-required QC trigger (when configured)
470
+ // Providers that require a PR URL run here after the PR is created.
471
+ await runQcGate({
472
+ config: config.qc,
473
+ state,
474
+ repoRoot,
475
+ branch,
476
+ trigger: "pr",
477
+ prUrl,
478
+ stepLabel: "[10.5/14]",
479
+ });
424
480
  // Step 11: Write PR URL to current-state.json
425
481
  console.log("[11/14] Writing PR URL to state...");
426
482
  state = (0, _09_update_state_js_1.stepUpdateState)(resolvedStateFile, state, prUrl);
@@ -4,6 +4,8 @@ exports.assertNotDoneState = assertNotDoneState;
4
4
  exports.findReviewState = findReviewState;
5
5
  exports.postLinearComment = postLinearComment;
6
6
  exports.updateLinearIssueAfterFinalize = updateLinearIssueAfterFinalize;
7
+ exports.buildFollowUpIssuePayload = buildFollowUpIssuePayload;
8
+ exports.createLinearFollowUpIssue = createLinearFollowUpIssue;
7
9
  const node_https_1 = require("node:https");
8
10
  const lifecycle_policy_js_1 = require("../tracker/lifecycle-policy.js");
9
11
  // ──────────────────────────────────────────────────────────────────────────────
@@ -211,3 +213,46 @@ async function updateLinearIssueAfterFinalize(options) {
211
213
  throw new Error(`Linear commentCreate failed: ${JSON.stringify(commentData.commentCreate)}`);
212
214
  }
213
215
  }
216
+ async function findIssueTeamId(issueId, apiKey) {
217
+ const issueData = await linearGraphQL(`query GetIssueTeam($id: String!) { issue(id: $id) { team { id } } }`, { id: issueId }, apiKey);
218
+ return issueData.issue?.team?.id ?? null;
219
+ }
220
+ /**
221
+ * Build the GraphQL variables for a Linear follow-up issue.
222
+ * Exported separately so tests can inspect the payload shape.
223
+ */
224
+ function buildFollowUpIssuePayload(options) {
225
+ const input = {
226
+ title: options.title,
227
+ description: options.description,
228
+ parentId: options.parentIssueId,
229
+ teamId: options.teamId,
230
+ };
231
+ if (options.stateId)
232
+ input.stateId = options.stateId;
233
+ if (options.labelIds && options.labelIds.length > 0)
234
+ input.labelIds = options.labelIds;
235
+ return {
236
+ query: `mutation CreateFollowUpIssue($input: IssueCreateInput!) {
237
+ issueCreate(input: $input) { success issue { id identifier url } }
238
+ }`,
239
+ variables: { input },
240
+ };
241
+ }
242
+ /**
243
+ * Create a Linear follow-up issue linked to a parent issue.
244
+ *
245
+ * This is the repair path for QC findings that are routed to follow-up work.
246
+ */
247
+ async function createLinearFollowUpIssue(options) {
248
+ const teamId = options.teamId ?? await findIssueTeamId(options.parentIssueId, options.apiKey);
249
+ if (!teamId) {
250
+ throw new Error(`Unable to resolve Linear team for parent issue ${options.parentIssueId}`);
251
+ }
252
+ const payload = buildFollowUpIssuePayload({ ...options, teamId });
253
+ const data = await linearGraphQL(payload.query, payload.variables, options.apiKey);
254
+ if (data.issueCreate?.success !== true || !data.issueCreate?.issue) {
255
+ throw new Error(`Linear issueCreate failed: ${JSON.stringify(data.issueCreate)}`);
256
+ }
257
+ return data.issueCreate.issue;
258
+ }
@@ -1,8 +1,61 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.generateRunReport = generateRunReport;
4
+ function renderQcSection(qcSummary) {
5
+ if (!qcSummary)
6
+ return "";
7
+ const { total_findings, blocking_findings, autofixed_findings, repaired_findings, waived_findings, unvalidated_findings, open_by_severity, blocks_delivery, qc_run_count, weighted_open_score, qc_penalty, provider_breakdown, routing_breakdown } = qcSummary;
8
+ const openTotal = open_by_severity.critical + open_by_severity.high +
9
+ open_by_severity.medium + open_by_severity.low + open_by_severity.info;
10
+ const deliveryStatus = blocks_delivery
11
+ ? "**BLOCKED** — unresolved critical/high findings must be addressed before delivery"
12
+ : "Not blocking delivery";
13
+ const providers = Object.entries(provider_breakdown)
14
+ .map(([provider, summary]) => `| ${provider} | ${summary.total} | ${summary.blocking} | ${summary.unvalidated} |`)
15
+ .join("\n");
16
+ const routing = `| original-worker | ${routing_breakdown.original_worker} |
17
+ | repair-worker | ${routing_breakdown.repair_worker} |
18
+ | follow-up | ${routing_breakdown.follow_up} |
19
+ | operator-review | ${routing_breakdown.operator_review} |
20
+ | unset | ${routing_breakdown.unset} |`;
21
+ const solImpactValue = qc_penalty > 0
22
+ ? `-${(qc_penalty * 100).toFixed(1)}% (weighted open score ${weighted_open_score.toFixed(2)})`
23
+ : `none (weighted open score ${weighted_open_score.toFixed(2)})`;
24
+ return `
25
+ ## QC summary
26
+
27
+ | Metric | Value |
28
+ |---|---|
29
+ | **QC runs** | ${qc_run_count} |
30
+ | **Delivery status** | ${deliveryStatus} |
31
+ | **Total findings** | ${total_findings} (${unvalidated_findings} unvalidated/provider-noise excluded from scoring) |
32
+ | **SOL score impact** | ${solImpactValue} |
33
+
34
+ | Status | Count |
35
+ |---|---|
36
+ | Blocking (critical/high, open) | ${blocking_findings} |
37
+ | Open (all severities) | ${openTotal} |
38
+ | Autofixed | ${autofixed_findings} |
39
+ | Repaired | ${repaired_findings} |
40
+ | Waived | ${waived_findings} |
41
+
42
+ | **Open by severity** | critical=${open_by_severity.critical} high=${open_by_severity.high} medium=${open_by_severity.medium} low=${open_by_severity.low} info=${open_by_severity.info} |
43
+
44
+ ### Providers
45
+
46
+ | Provider | Total | Blocking | Unvalidated |
47
+ |---|---|---|---|
48
+ ${providers || "| _none_ | — | — | — |"}
49
+
50
+ ### Repair routing
51
+
52
+ | Decision | Count |
53
+ |---|---|
54
+ ${routing}
55
+ `;
56
+ }
4
57
  function generateRunReport(data) {
5
- const { state, branch, validationPassed, prUrl, artifacts, notes } = data;
58
+ const { state, branch, validationPassed, prUrl, artifacts, notes, qcSummary } = data;
6
59
  const total = state.completed_children.length + state.open_children.length;
7
60
  const completedCount = state.completed_children.length;
8
61
  const childRows = state.completed_children
@@ -18,19 +71,22 @@ function generateRunReport(data) {
18
71
  const blockerNote = state.blocker
19
72
  ? `\n**Blocker:** ${state.blocker.reason} (child: ${state.blocker.child_id})\n`
20
73
  : "";
74
+ const qcSection = renderQcSection(qcSummary);
21
75
  return `# Run Report: ${state.run_id}
22
76
 
23
- **Status:** ${state.status}
24
- **Branch:** ${branch}
25
- **PR:** ${prUrl ?? "TBD — set at delivery step 9"}
26
- **Children completed:** ${completedCount} of ${total}
27
- **Validation:** ${validationPassed ? "passed" : "failed"}
28
- ${blockerNote}
29
- ## Children
77
+ | Field | Value |
78
+ |---|---|
79
+ | **Status** | ${state.status} |
80
+ | **Branch** | ${branch} |
81
+ | **PR** | ${prUrl ?? "TBD set at delivery step 9"} |
82
+ | **Children completed** | ${completedCount} of ${total} |
83
+ | **Validation** | ${validationPassed ? "passed" : "failed"} |
84
+ ${blockerNote}
85
+ ## Children
30
86
 
31
- | ID | Title | Commit | Status |
32
- |---|---|---|---|
33
- ${childRows || "_No children recorded_"}
87
+ | ID | Title | Commit | Status |
88
+ |---|---|---|---|
89
+ ${childRows || "_No children recorded_"}
34
90
 
35
91
  ## Artifacts produced
36
92
 
@@ -39,7 +95,7 @@ ${artifactList}
39
95
  ## Validation summary
40
96
 
41
97
  ${validationPassed ? "Map validate passed. Schema validate passed." : "One or more validations failed — see step output above."}
42
-
98
+ ${qcSection}
43
99
  ## Notes
44
100
 
45
101
  ${notesList}
@@ -65,7 +65,6 @@ function writeSealedResultFromSummary(packet, parsedSummary) {
65
65
  const validation = parsedSummary["validation"] ?? parsedSummary["validation_summary"];
66
66
  const sealedResult = {
67
67
  run_id: packet.run_id,
68
- cluster_id: packet.cluster_id,
69
68
  child_id: packet.active_child,
70
69
  status: normalizeSealedStatus(parsedSummary["status"]),
71
70
  };
@@ -501,9 +501,7 @@ class TerminalCliAdapter {
501
501
  // that return minimal status objects instead of proper CompactReturn structs.
502
502
  const effectiveStatus = (exitCode === 0 && isValidCompactReturn) ? "success" : "failure";
503
503
  const sealedResult = {
504
- ...normalized,
505
504
  run_id: packet.run_id,
506
- cluster_id: packet.cluster_id,
507
505
  child_id: String(normalized["child_id"] ?? packet.active_child),
508
506
  status: effectiveStatus,
509
507
  commit: typeof normalized["commit"] === "string"
@@ -519,7 +517,6 @@ class TerminalCliAdapter {
519
517
  ? normalized["error_message"]
520
518
  : stdout || summary)
521
519
  : undefined,
522
- ...(compactReturnErrors.length > 0 ? { compact_return_errors: compactReturnErrors } : {}),
523
520
  };
524
521
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(resultFile), { recursive: true });
525
522
  node_fs_1.default.writeFileSync(resultFile, JSON.stringify(sealedResult, null, 2), "utf-8");
@@ -40,8 +40,10 @@ exports.validateTransition = validateTransition;
40
40
  exports.initialDispatchBoundary = initialDispatchBoundary;
41
41
  exports.advanceDispatchEpoch = advanceDispatchEpoch;
42
42
  exports.advanceContinueEpoch = advanceContinueEpoch;
43
+ exports.assertChildQcSelectionAllowed = assertChildQcSelectionAllowed;
43
44
  const node_fs_1 = require("node:fs");
44
45
  const node_path_1 = require("node:path");
46
+ const triggers_js_1 = require("../qc/triggers.js");
45
47
  // ──────────────────────────────────────────────────────────────────────────────
46
48
  // Error constants
47
49
  // ──────────────────────────────────────────────────────────────────────────────
@@ -333,3 +335,30 @@ function advanceContinueEpoch(current) {
333
335
  last_dispatched_child: current.last_dispatched_child,
334
336
  };
335
337
  }
338
+ /**
339
+ * Validate that a child-level QC trigger is only selected when permitted.
340
+ *
341
+ * Child-level QC is opt-in: high-risk scope, route policy, or explicit operator
342
+ * request. If a packet carries child-level QC without meeting one of those
343
+ * conditions, the dispatch boundary is violated.
344
+ */
345
+ function assertChildQcSelectionAllowed(state, childId, config, allowedScope, labels, telemetryFile) {
346
+ const meta = state.open_children_meta?.[childId];
347
+ if (meta?.qc_trigger !== "child")
348
+ return;
349
+ const routeName = childId; // route-specific overrides are keyed by child/route identifier
350
+ if ((0, triggers_js_1.isChildQcSelected)(config, childId, allowedScope, labels, routeName)) {
351
+ return;
352
+ }
353
+ const reason = `Child-level QC selected for ${childId} but no opt-in condition is met (high-risk scope, route policy, or "qc-child" label)`;
354
+ appendDispatchViolationEvent(telemetryFile, {
355
+ event: "illegal-state-transition",
356
+ run_id: state.run_id,
357
+ child_id: childId,
358
+ from_state: getMachineState(state),
359
+ to_state: "dispatched",
360
+ reason,
361
+ timestamp: new Date().toISOString(),
362
+ });
363
+ throw new Error(reason);
364
+ }