@lsctech/polaris 0.5.7 → 0.5.8

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.
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createAutoresearchCommand = void 0;
4
4
  exports.createSolCommand = createSolCommand;
5
5
  const node_path_1 = require("node:path");
6
+ const node_fs_1 = require("node:fs");
6
7
  const commander_1 = require("commander");
7
8
  const dev_gate_js_1 = require("../autoresearch/dev-gate.js");
8
9
  const score_js_1 = require("../autoresearch/score.js");
@@ -12,6 +13,9 @@ const sol_evidence_loader_js_1 = require("../autoresearch/sol-evidence-loader.js
12
13
  const sol_scorer_js_1 = require("../autoresearch/sol-scorer.js");
13
14
  const sol_history_js_1 = require("../autoresearch/sol-history.js");
14
15
  const sol_report_js_1 = require("../autoresearch/sol-report.js");
16
+ const sol_scorecard_calculator_js_1 = require("../autoresearch/sol-scorecard-calculator.js");
17
+ const sol_evaluation_writer_js_1 = require("../autoresearch/sol-evaluation-writer.js");
18
+ const sol_report_renderer_js_1 = require("../autoresearch/sol-report-renderer.js");
15
19
  const sol_recommendations_js_1 = require("../autoresearch/sol-recommendations.js");
16
20
  function createSolCommand(options) {
17
21
  const repoRoot = options.repoRoot;
@@ -74,6 +78,79 @@ function createSolCommand(options) {
74
78
  process.exit(1);
75
79
  }
76
80
  });
81
+ sol
82
+ .command("report <run-id>")
83
+ .description("Generate SOL evaluation artifacts and a human-readable SmartDocs report for a run")
84
+ .option("-r, --repo-root <path>", "Repository root", repoRoot)
85
+ .option("--format <mode>", "Output format: markdown or json", "markdown")
86
+ .option("--json", "Output raw JSON (same as --format json)")
87
+ .option("--no-write", "Skip writing artifact files")
88
+ .action((runId, cmdOptions) => {
89
+ const root = (0, node_path_1.resolve)(cmdOptions.repoRoot ?? repoRoot);
90
+ if (cmdOptions.format !== "markdown" && cmdOptions.format !== "json") {
91
+ process.stderr.write(`sol report error: invalid --format "${cmdOptions.format}" (expected "markdown" or "json")\n`);
92
+ process.exit(1);
93
+ }
94
+ try {
95
+ (0, dev_gate_js_1.assertPolarisDevContext)(root);
96
+ }
97
+ catch (err) {
98
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
99
+ process.exit(1);
100
+ }
101
+ let artifacts;
102
+ let evidence;
103
+ try {
104
+ artifacts = (0, score_js_1.loadRunArtifacts)(root, runId);
105
+ evidence = (0, sol_evidence_loader_js_1.aggregateSolEvidence)(artifacts);
106
+ }
107
+ catch (err) {
108
+ process.stderr.write(`sol report error: cannot load run artifacts: ${err instanceof Error ? err.message : String(err)}\n`);
109
+ process.exit(1);
110
+ }
111
+ try {
112
+ const report = (0, sol_scorer_js_1.computeSolScoreReport)(evidence);
113
+ const scorecards = (0, sol_scorecard_calculator_js_1.computeAllScorecards)(evidence);
114
+ const qcRecommendations = (0, sol_recommendations_js_1.generateQcRecommendations)(evidence);
115
+ let evaluationPath;
116
+ let scorecardPaths;
117
+ let markdownPath;
118
+ const evaluationRecord = (0, sol_evaluation_writer_js_1.buildEvaluationRecord)(report);
119
+ const rendered = (0, sol_report_renderer_js_1.renderSolMarkdown)(evaluationRecord, scorecards, qcRecommendations);
120
+ if (cmdOptions.write) {
121
+ // Persist the same evaluationRecord object that was rendered, rather than rebuilding internally
122
+ const evaluationDir = (0, sol_evaluation_writer_js_1.getSolEvaluationsDir)(root);
123
+ if (!(0, node_fs_1.existsSync)(evaluationDir))
124
+ (0, node_fs_1.mkdirSync)(evaluationDir, { recursive: true });
125
+ evaluationPath = (0, sol_evaluation_writer_js_1.getEvaluationRecordPath)(root, report.run_id);
126
+ (0, node_fs_1.writeFileSync)(evaluationPath, JSON.stringify(evaluationRecord, null, 2) + "\n", "utf-8");
127
+ scorecardPaths = (0, sol_evaluation_writer_js_1.writeScorecardSet)(root, scorecards);
128
+ markdownPath = (0, sol_evaluation_writer_js_1.writeSolMarkdownReport)(root, runId, rendered.markdown);
129
+ }
130
+ const outputFormat = cmdOptions.json || cmdOptions.format === "json" ? "json" : "markdown";
131
+ if (outputFormat === "json") {
132
+ const output = JSON.stringify({
133
+ run_id: runId,
134
+ evaluation: evaluationRecord,
135
+ scorecards,
136
+ qc_recommendations: qcRecommendations,
137
+ artifacts: {
138
+ evaluation: evaluationPath,
139
+ scorecards: scorecardPaths,
140
+ markdown: markdownPath,
141
+ },
142
+ }, null, 2);
143
+ process.stdout.write(`${output}\n`);
144
+ }
145
+ else {
146
+ process.stdout.write(rendered.markdown);
147
+ }
148
+ }
149
+ catch (err) {
150
+ process.stderr.write(`sol report error: ${err instanceof Error ? err.message : String(err)}\n`);
151
+ process.exit(1);
152
+ }
153
+ });
77
154
  sol
78
155
  .command("propose <diagnosis-file>")
79
156
  .description("File Linear improvement proposals from a diagnosis report (dev-gated — never auto-applied)")
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.recordQcRun = exports.initializeClusterState = exports.writeClusterStateSync = exports.writeClusterState = exports.readClusterStateSync = exports.readClusterState = void 0;
37
37
  exports.pruneExpiredClaims = pruneExpiredClaims;
38
+ exports.pruneMissingQcRunPointers = pruneMissingQcRunPointers;
38
39
  const fs_1 = require("fs");
39
40
  const path = __importStar(require("path"));
40
41
  const local_graph_1 = require("../tracker/local-graph");
@@ -350,12 +351,25 @@ const recordQcRun = async (clusterId, result, repoRoot) => {
350
351
  if (!currentState) {
351
352
  throw new Error(`Cluster ${clusterId} state not found; cannot record QC run ${result.qcRunId}.`);
352
353
  }
354
+ const rawArtifactPaths = result.rawArtifactPaths ?? [];
355
+ const providerAttemptPath = result.providerAttempt?.rawOutputArtifactPath;
356
+ const auditArtifactPaths = providerAttemptPath
357
+ ? [...rawArtifactPaths, providerAttemptPath]
358
+ : rawArtifactPaths;
359
+ const failedRun = result.status === "failed" || result.status === "blocked" || result.allProvidersFailed === true;
360
+ let availability = "available";
361
+ if (failedRun && auditArtifactPaths.some((p) => !(0, fs_1.existsSync)(p))) {
362
+ availability = "unavailable";
363
+ }
353
364
  const pointer = {
354
365
  artifact_path: artifactPath,
355
366
  status: result.status,
356
367
  provider: result.provider,
357
368
  started_at: result.startedAt,
358
369
  completed_at: result.completedAt,
370
+ availability,
371
+ raw_artifact_paths: rawArtifactPaths.length > 0 ? rawArtifactPaths : undefined,
372
+ provider_attempt_artifact_path: providerAttemptPath,
359
373
  };
360
374
  const nextState = {
361
375
  ...currentState,
@@ -369,3 +383,45 @@ const recordQcRun = async (clusterId, result, repoRoot) => {
369
383
  return { artifactPath, state: nextState };
370
384
  };
371
385
  exports.recordQcRun = recordQcRun;
386
+ /**
387
+ * Remove QC run pointers whose primary artifacts are missing and mark
388
+ * pointers with missing raw audit artifacts as unavailable. Returns a new
389
+ * state object; callers must persist the result if they want the cleanup to
390
+ * be durable.
391
+ */
392
+ function pruneMissingQcRunPointers(state) {
393
+ const warnings = [];
394
+ const pruned = [];
395
+ if (!state.qc_runs) {
396
+ return { state, pruned, warnings };
397
+ }
398
+ const validation = (0, artifacts_js_1.validateQcArtifactPointers)(state.qc_runs);
399
+ if (validation.ok && validation.unavailable.length === 0) {
400
+ return { state, pruned, warnings };
401
+ }
402
+ const nextQcRuns = {};
403
+ for (const [runId, pointer] of Object.entries(state.qc_runs)) {
404
+ if (validation.missing.includes(pointer.artifact_path)) {
405
+ pruned.push(runId);
406
+ warnings.push(`QC run ${runId} pointer pruned: artifact missing ${pointer.artifact_path}`);
407
+ continue;
408
+ }
409
+ const updated = { ...pointer };
410
+ const hasMissingRaw = (pointer.raw_artifact_paths ?? []).some((p) => validation.unavailable.includes(p)) ||
411
+ (pointer.provider_attempt_artifact_path &&
412
+ validation.unavailable.includes(pointer.provider_attempt_artifact_path));
413
+ if (hasMissingRaw && updated.availability === "available") {
414
+ updated.availability = "unavailable";
415
+ warnings.push(`QC run ${runId} raw audit artifacts missing; marked unavailable`);
416
+ }
417
+ nextQcRuns[runId] = updated;
418
+ }
419
+ return {
420
+ state: {
421
+ ...state,
422
+ qc_runs: nextQcRuns,
423
+ },
424
+ pruned,
425
+ warnings,
426
+ };
427
+ }
@@ -37,6 +37,7 @@ function isPromotedClusterArtifact(relativePath, activeClusterId) {
37
37
  const suffix = relativePath.slice(activeClusterPrefix.length);
38
38
  return (suffix === "clusters.json"
39
39
  || suffix === "cluster-state.json"
40
+ || suffix === "state.json"
40
41
  || suffix.startsWith("packets/")
41
42
  || suffix.startsWith("results/")
42
43
  || suffix.startsWith("qc/"));
@@ -131,6 +132,7 @@ function getArtifactPromotionPolicy(activeClusterId) {
131
132
  promoted: [
132
133
  `${activeClusterPrefix}clusters.json`,
133
134
  `${activeClusterPrefix}cluster-state.json`,
135
+ `${activeClusterPrefix}state.json`,
134
136
  `${activeClusterPrefix}packets/**`,
135
137
  `${activeClusterPrefix}results/**`,
136
138
  `${activeClusterPrefix}qc/**`,
@@ -1,9 +1,20 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildPrBody = buildPrBody;
3
4
  exports.createDraftPr = createDraftPr;
4
5
  const node_child_process_1 = require("node:child_process");
6
+ function buildPrBody(state, branch, authoritativeChildCount) {
7
+ return [
8
+ `**Cluster ID:** ${state.cluster_id}`,
9
+ `**Run ID:** ${state.run_id}`,
10
+ `**Branch:** ${branch}`,
11
+ `**Children completed:** ${authoritativeChildCount ?? state.completed_children.length}`,
12
+ ``,
13
+ `_Generated by polaris finalize_`,
14
+ ].join("\n");
15
+ }
5
16
  function createDraftPr(options) {
6
- const { repoRoot, branch, state, draft } = options;
17
+ const { repoRoot, branch, state, draft, authoritativeChildCount } = options;
7
18
  if (!state.cluster_id) {
8
19
  throw new Error(`createDraftPr: state.cluster_id is empty — cannot create PR without a cluster identifier`);
9
20
  }
@@ -16,14 +27,7 @@ function createDraftPr(options) {
16
27
  `branch/cluster mismatch, aborting PR creation`);
17
28
  }
18
29
  const title = `polaris finalize: ${state.cluster_id} (${state.run_id})`;
19
- const body = [
20
- `**Cluster ID:** ${state.cluster_id}`,
21
- `**Run ID:** ${state.run_id}`,
22
- `**Branch:** ${branch}`,
23
- `**Children completed:** ${state.completed_children.length}`,
24
- ``,
25
- `_Generated by polaris finalize_`,
26
- ].join("\n");
30
+ const body = buildPrBody(state, branch, authoritativeChildCount);
27
31
  const args = [
28
32
  "pr", "create",
29
33
  "--title", title,
@@ -1,5 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateStateFileAuthority = validateStateFileAuthority;
4
+ exports.validateQcRepairLoopGate = validateQcRepairLoopGate;
5
+ exports.warnOnMissingQcArtifacts = warnOnMissingQcArtifacts;
6
+ exports.validateAuthoritativeChildState = validateAuthoritativeChildState;
3
7
  exports.runFinalize = runFinalize;
4
8
  exports.createFinalizeCommand = createFinalizeCommand;
5
9
  const commander_1 = require("commander");
@@ -12,6 +16,7 @@ const checkpoint_js_1 = require("../loop/checkpoint.js");
12
16
  const artifact_policy_js_1 = require("./artifact-policy.js");
13
17
  const git_custody_js_1 = require("../loop/git-custody.js");
14
18
  const store_js_1 = require("../cluster-state/store.js");
19
+ const artifacts_js_1 = require("../qc/artifacts.js");
15
20
  const canon_check_js_1 = require("../smartdocs-engine/canon-check.js");
16
21
  const _01_map_update_js_1 = require("./steps/01-map-update.js");
17
22
  const _02_map_validate_js_1 = require("./steps/02-map-validate.js");
@@ -85,6 +90,60 @@ function validateStateBranchMatchesGitBranch(stateBranch, branch) {
85
90
  process.exit(1);
86
91
  }
87
92
  }
93
+ const CLUSTER_STATE_FILE_PATTERN = /[\\/]\.polaris[\\/]clusters[\\/][^\\/]+[\\/]state\.json$/;
94
+ function isClusterStateSnapshotFile(filePath) {
95
+ return CLUSTER_STATE_FILE_PATTERN.test(filePath.replace(/\\/g, "/"));
96
+ }
97
+ function computeRunStateFreshness(state) {
98
+ const s = state;
99
+ let score = 0;
100
+ score += (s.completed_children?.length ?? 0) * 10;
101
+ if (s.status === "complete")
102
+ score += 50;
103
+ if (s.pr_url)
104
+ score += 30;
105
+ if (s.qc_repair_loop?.terminal_outcome)
106
+ score += 40;
107
+ score += (s.dispatch_boundary?.dispatch_epoch ?? 0) * 5;
108
+ score += (s.context_budget?.children_completed ?? 0) * 2;
109
+ return score;
110
+ }
111
+ /**
112
+ * Refuse to use a promoted cluster state snapshot when the authoritative
113
+ * taskchain current-state.json is newer. Cluster snapshots under
114
+ * `.polaris/clusters/<id>/state.json` are secondary; the taskchain state
115
+ * owned by the parent loop is authoritative.
116
+ */
117
+ function validateStateFileAuthority(stateFile, state, repoRoot) {
118
+ if (!isClusterStateSnapshotFile(stateFile)) {
119
+ return;
120
+ }
121
+ const taskchainStateFile = (0, node_path_1.join)(repoRoot, ".taskchain_artifacts", "polaris-run", "current-state.json");
122
+ if (!(0, node_fs_1.existsSync)(taskchainStateFile)) {
123
+ return;
124
+ }
125
+ let taskchainState;
126
+ try {
127
+ taskchainState = (0, checkpoint_js_1.readState)(taskchainStateFile);
128
+ }
129
+ catch {
130
+ return;
131
+ }
132
+ if (taskchainState.run_id !== state.run_id ||
133
+ taskchainState.cluster_id !== state.cluster_id) {
134
+ return;
135
+ }
136
+ const taskchainFreshness = computeRunStateFreshness(taskchainState);
137
+ const snapshotFreshness = computeRunStateFreshness(state);
138
+ if (taskchainFreshness > snapshotFreshness) {
139
+ process.stderr.write(`finalize aborted: cluster state snapshot is stale.\n` +
140
+ ` Snapshot: ${stateFile}\n` +
141
+ ` Authoritative taskchain state: ${taskchainStateFile}\n` +
142
+ `The taskchain current-state.json is newer; use it instead, or ` +
143
+ `update the cluster snapshot before finalizing.\n`);
144
+ process.exit(1);
145
+ }
146
+ }
88
147
  /**
89
148
  * Check that the Closeout Librarian has run and its result passes the gate.
90
149
  * Returns null if finalize may proceed; returns a human-readable blocker string if not.
@@ -183,8 +242,106 @@ function resolveQcTelemetryFile(state, repoRoot) {
183
242
  const artifactDir = state.artifact_dir ?? (0, node_path_1.join)(repoRoot, ".taskchain_artifacts", "polaris-run");
184
243
  return (0, node_path_1.join)(artifactDir, "runs", state.run_id, "telemetry.jsonl");
185
244
  }
245
+ // ── QC repair-loop terminal state gate ────────────────────────────────────────
246
+ /**
247
+ * Trusted QC repair-loop terminal outcomes that allow finalize to proceed.
248
+ * "pass" = QC rerun passed; "qc-disabled" = QC was off; "no-repairable" = no
249
+ * findings routable to repair workers (follow-up-only or already resolved).
250
+ */
251
+ const TRUSTED_QC_REPAIR_OUTCOMES = new Set(["pass", "qc-disabled", "no-repairable"]);
252
+ /**
253
+ * Validate QC repair-loop terminal state when QC is enabled and repair routing
254
+ * is active. Returns null when finalize may proceed; returns a human-readable
255
+ * blocker string otherwise.
256
+ */
257
+ function validateQcRepairLoopGate(state, config) {
258
+ // Gate only applies when QC is enabled
259
+ if (!config.enabled)
260
+ return null;
261
+ // Gate only applies when repair routing is active (not "log" or "block")
262
+ const repairRouting = config.repairRouting ?? "route";
263
+ if (repairRouting !== "route" && repairRouting !== "follow-up")
264
+ return null;
265
+ const repairLoop = state.qc_repair_loop;
266
+ // No repair-loop state at all — the completed-cluster QC trigger ran but
267
+ // never entered the repair loop. This is a gap: finalize should not proceed.
268
+ if (!repairLoop) {
269
+ return ("QC is enabled with repair routing active, but no qc_repair_loop state " +
270
+ "was found in the run state. The parent loop must run the QC repair loop " +
271
+ "before finalize can proceed.");
272
+ }
273
+ const outcome = repairLoop.terminal_outcome;
274
+ if (outcome === null || outcome === undefined) {
275
+ return ("QC repair loop is still in-flight (terminal_outcome is null). " +
276
+ "Finalize cannot proceed until the repair loop reaches a terminal state.");
277
+ }
278
+ if (TRUSTED_QC_REPAIR_OUTCOMES.has(outcome))
279
+ return null;
280
+ return (`QC repair loop terminated with untrusted outcome: "${outcome}". ` +
281
+ `Only ${Array.from(TRUSTED_QC_REPAIR_OUTCOMES).join(", ")} outcomes allow finalize to proceed. ` +
282
+ `Resolve the repair loop before re-running finalize.`);
283
+ }
284
+ // ── Authoritative completed-child state cross-check ───────────────────────────
285
+ function warnOnMissingQcArtifacts(clusterState, repoRoot) {
286
+ if (!clusterState)
287
+ return;
288
+ const validation = (0, artifacts_js_1.validateQcArtifactPointers)(clusterState.qc_runs);
289
+ if (validation.ok)
290
+ return;
291
+ for (const missing of validation.missing) {
292
+ console.warn(`finalize warning: cluster-state QC pointer references missing artifact: ${missing}`);
293
+ }
294
+ for (const unavailable of validation.unavailable) {
295
+ console.warn(`finalize warning: QC audit artifact unavailable: ${unavailable}`);
296
+ }
297
+ }
298
+ /**
299
+ * Cross-check completed children in the loop state against the cluster-state
300
+ * child_states. Returns a diagnostic result — when `ok` is false, finalize
301
+ * should refuse PR creation.
302
+ */
303
+ function validateAuthoritativeChildState(state, repoRoot) {
304
+ const clusterState = (0, store_js_1.readClusterStateSync)(state.cluster_id, repoRoot);
305
+ const stateCount = state.completed_children.length;
306
+ // No cluster state available — backward-compat: trust the loop state.
307
+ if (!clusterState) {
308
+ return { ok: true, authoritativeCount: stateCount, stateCount };
309
+ }
310
+ const childStates = clusterState.child_states;
311
+ // No child_states array in cluster state — backward-compat: trust the loop state.
312
+ // This covers legacy cluster-state files that don't track individual child lifecycle.
313
+ if (!childStates || childStates.length === 0) {
314
+ return { ok: true, authoritativeCount: stateCount, stateCount };
315
+ }
316
+ const doneChildren = childStates.filter((c) => c.status === "done" || c.status === "reviewed" || c.status === "finalized");
317
+ const authoritativeCount = doneChildren.length;
318
+ // When cluster-state has tracked children but zero are done while loop state
319
+ // claims completions, the cluster state file is stale or was never updated.
320
+ if (authoritativeCount === 0 && stateCount > 0) {
321
+ return {
322
+ ok: false,
323
+ authoritativeCount,
324
+ stateCount,
325
+ reason: `Stale cluster state: cluster-state.json has 0 done children but ` +
326
+ `loop state claims ${stateCount} completed. ` +
327
+ `The cluster state file was never updated by worker completions.`,
328
+ };
329
+ }
330
+ // Significant mismatch: cluster-state has fewer done children than loop state
331
+ if (authoritativeCount < stateCount) {
332
+ return {
333
+ ok: false,
334
+ authoritativeCount,
335
+ stateCount,
336
+ reason: `Completed-child count mismatch: cluster-state has ${authoritativeCount} done ` +
337
+ `children but loop state has ${stateCount}. ` +
338
+ `The cluster state may be stale or child completions were not recorded properly.`,
339
+ };
340
+ }
341
+ return { ok: true, authoritativeCount, stateCount };
342
+ }
186
343
  async function runQcGate(options) {
187
- const { config, state, repoRoot, branch, trigger, prUrl, stepLabel } = options;
344
+ const { config, state, repoRoot, branch, baseRef, trigger, prUrl, stepLabel } = options;
188
345
  const registry = (0, index_js_2.createQcRegistry)(config);
189
346
  const result = await (0, index_js_2.runQcAtTrigger)({
190
347
  config,
@@ -195,6 +352,7 @@ async function runQcGate(options) {
195
352
  runId: state.run_id,
196
353
  clusterId: state.cluster_id,
197
354
  branch,
355
+ baseRef,
198
356
  telemetryFile: resolveQcTelemetryFile(state, repoRoot),
199
357
  state,
200
358
  });
@@ -236,6 +394,7 @@ async function runFinalize(options) {
236
394
  validateStateFilePath(stateFile);
237
395
  validateClusterIdMatchesBranch(state.cluster_id, branch);
238
396
  validateStateBranchMatchesGitBranch(state.branch, branch);
397
+ validateStateFileAuthority(stateFile, state, repoRoot);
239
398
  // Step 4: Run configured checks
240
399
  const checks = config.finalize?.runChecks ?? [];
241
400
  if (checks.length > 0) {
@@ -322,6 +481,7 @@ async function runFinalize(options) {
322
481
  // loop.allowBranchDivergence is true (direct-main mode).
323
482
  {
324
483
  const clusterState = (0, store_js_1.readClusterStateSync)(state.cluster_id, repoRoot);
484
+ warnOnMissingQcArtifacts(clusterState, repoRoot);
325
485
  const baseBranch = clusterState?.base_branch;
326
486
  const deliveryBranch = clusterState?.delivery_branch;
327
487
  const directMainMode = config.loop?.allowBranchDivergence === true;
@@ -389,14 +549,37 @@ async function runFinalize(options) {
389
549
  }
390
550
  // Step 5.8: Completed-cluster QC trigger (when configured)
391
551
  // Runs after all authoritative gates and before final commit/delivery.
552
+ const clusterStateForQc = (0, store_js_1.readClusterStateSync)(state.cluster_id, repoRoot);
553
+ const qcBaseRef = clusterStateForQc?.base_branch ??
554
+ config.finalize?.targetBranch ??
555
+ "main";
392
556
  await runQcGate({
393
557
  config: config.qc,
394
558
  state,
395
559
  repoRoot,
396
560
  branch,
561
+ baseRef: qcBaseRef,
397
562
  trigger: "completed-cluster",
398
563
  stepLabel: "[5.8/14]",
399
564
  });
565
+ // Step 5.9: QC repair-loop terminal state gate
566
+ // When QC is enabled and repair routing is active, require a trusted
567
+ // terminal outcome from the QC repair loop before allowing PR creation.
568
+ {
569
+ const repairLoopBlocker = validateQcRepairLoopGate(state, config.qc);
570
+ if (repairLoopBlocker) {
571
+ process.stderr.write(`finalize aborted: QC repair-loop gate failed.\n${repairLoopBlocker}\n`);
572
+ process.exit(1);
573
+ }
574
+ }
575
+ // Step 5.10: Authoritative completed-child state cross-check
576
+ // Verify that the completed children claimed by the loop state match
577
+ // the cluster-state child_states. Blocks on stale or inconsistent state.
578
+ const authChildResult = validateAuthoritativeChildState(state, repoRoot);
579
+ if (!authChildResult.ok) {
580
+ process.stderr.write(`finalize aborted: authoritative child state mismatch.\n${authChildResult.reason}\n`);
581
+ process.exit(1);
582
+ }
400
583
  // Step 6: Tracker Reconciliation
401
584
  // LinearAdapter is sync-in only; only McpBridgeAdapter supports full reconciliation.
402
585
  const trackerType = config.tracker?.adapter;
@@ -465,7 +648,7 @@ async function runFinalize(options) {
465
648
  // Step 10: Create draft PR
466
649
  const prDraft = config.finalize?.prDraft ?? true;
467
650
  console.log("[10/14] Creating draft PR...");
468
- const prUrl = (0, _08_create_pr_js_1.stepCreatePr)(repoRoot, branch, state, prDraft);
651
+ const prUrl = (0, _08_create_pr_js_1.stepCreatePr)(repoRoot, branch, state, prDraft, authChildResult.authoritativeCount);
469
652
  // Step 10.5: PR-required QC trigger (when configured)
470
653
  // Providers that require a PR URL run here after the PR is created.
471
654
  await runQcGate({
@@ -480,6 +663,17 @@ async function runFinalize(options) {
480
663
  // Step 11: Write PR URL to current-state.json
481
664
  console.log("[11/14] Writing PR URL to state...");
482
665
  state = (0, _09_update_state_js_1.stepUpdateState)(resolvedStateFile, state, prUrl);
666
+ // Promote the authoritative run state into the cluster snapshot so that
667
+ // `.polaris/clusters/<id>/state.json` preserves completed children, the
668
+ // dispatch boundary, QC repair-loop terminal state, and the PR URL.
669
+ try {
670
+ const clusterStateSnapshotPath = (0, node_path_1.join)(repoRoot, ".polaris", "clusters", state.cluster_id, "state.json");
671
+ (0, checkpoint_js_1.writeStateAtomic)(clusterStateSnapshotPath, state);
672
+ }
673
+ catch (snapshotError) {
674
+ process.stderr.write(`[11/14] WARNING: Failed to write cluster state snapshot: ${snapshotError instanceof Error ? snapshotError.message : String(snapshotError)}\n`);
675
+ process.stderr.write(`Delivery will proceed, but cluster state snapshot at .polaris/clusters/${state.cluster_id}/state.json may be incomplete.\n`);
676
+ }
483
677
  // Step 12: Append JSONL events
484
678
  console.log("[12/14] Appending JSONL events...");
485
679
  const artifactDir = state.artifact_dir ?? (0, node_path_1.join)(repoRoot, ".taskchain_artifacts", "polaris-run");
@@ -489,7 +683,7 @@ async function runFinalize(options) {
489
683
  console.log("[13/14] Updating Linear...");
490
684
  const linearEnabled = config.tracker?.linear?.enabled ?? false;
491
685
  const lifecyclePolicy = config.tracker?.lifecyclePolicy;
492
- await (0, _11_update_linear_js_1.stepUpdateLinear)(state, branch, prUrl, true, linearEnabled, state.cluster_id, lifecyclePolicy);
686
+ await (0, _11_update_linear_js_1.stepUpdateLinear)(state, branch, prUrl, true, linearEnabled, state.cluster_id, lifecyclePolicy, authChildResult.authoritativeCount);
493
687
  // Step 14: Archive run snapshot
494
688
  console.log("[14/14] Archiving run snapshot...");
495
689
  (0, _12_archive_js_1.stepArchive)(repoRoot, state, resolvedStateFile, reportPath);
@@ -97,6 +97,7 @@ async function findReviewState(issueId, apiKey) {
97
97
  // Comment body builder
98
98
  // ──────────────────────────────────────────────────────────────────────────────
99
99
  function buildCommentBody(opts) {
100
+ const childCount = opts.authoritativeChildCount ?? opts.state.completed_children.length;
100
101
  const lines = [
101
102
  `**polaris finalize complete** — run \`${opts.state.run_id}\``,
102
103
  ``,
@@ -104,7 +105,7 @@ function buildCommentBody(opts) {
104
105
  `|---|---|`,
105
106
  `| Branch | \`${opts.branch}\` |`,
106
107
  `| PR | ${opts.prUrl} |`,
107
- `| Children completed | ${opts.state.completed_children.length} |`,
108
+ `| Children completed | ${childCount} |`,
108
109
  `| Map validation | ${opts.validationPassed ? "✓ passed" : "✗ failed"} |`,
109
110
  ];
110
111
  if (opts.reviewStateMissing) {
@@ -117,8 +118,8 @@ function buildCommentBody(opts) {
117
118
  // ──────────────────────────────────────────────────────────────────────────────
118
119
  /** Posts a finalize-complete comment without attempting a state transition. */
119
120
  async function postLinearComment(options) {
120
- const { issueId, state, branch, prUrl, validationPassed, apiKey } = options;
121
- const body = buildCommentBody({ state, branch, prUrl, validationPassed, reviewStateMissing: false });
121
+ const { issueId, state, branch, prUrl, validationPassed, apiKey, authoritativeChildCount } = options;
122
+ const body = buildCommentBody({ state, branch, prUrl, validationPassed, reviewStateMissing: false, authoritativeChildCount });
122
123
  const data = await linearGraphQL(`mutation CreateComment($issueId: String!, $body: String!) {
123
124
  commentCreate(input: { issueId: $issueId, body: $body }) { success }
124
125
  }`, { issueId, body }, apiKey);
@@ -140,7 +141,7 @@ async function postLinearComment(options) {
140
141
  * state ID. The assertNotDoneState guard enforces this at runtime.
141
142
  */
142
143
  async function updateLinearIssueAfterFinalize(options) {
143
- const { issueId, state, branch, prUrl, validationPassed, apiKey, lifecyclePolicy } = options;
144
+ const { issueId, state, branch, prUrl, validationPassed, apiKey, lifecyclePolicy, authoritativeChildCount } = options;
144
145
  // Resolve lifecycle transition from policy
145
146
  const lifecycleTransition = (0, lifecycle_policy_js_1.resolveLifecycleTransition)("parent-all-children-complete", lifecyclePolicy);
146
147
  // If policy says skip, skip the state transition entirely
@@ -152,6 +153,7 @@ async function updateLinearIssueAfterFinalize(options) {
152
153
  prUrl,
153
154
  validationPassed,
154
155
  reviewStateMissing: false,
156
+ authoritativeChildCount,
155
157
  });
156
158
  const commentData = await linearGraphQL(`mutation CreateComment($issueId: String!, $body: String!) {
157
159
  commentCreate(input: { issueId: $issueId, body: $body }) { success }
@@ -172,6 +174,7 @@ async function updateLinearIssueAfterFinalize(options) {
172
174
  prUrl,
173
175
  validationPassed,
174
176
  reviewStateMissing: false,
177
+ authoritativeChildCount,
175
178
  });
176
179
  const commentData = await linearGraphQL(`mutation CreateComment($issueId: String!, $body: String!) {
177
180
  commentCreate(input: { issueId: $issueId, body: $body }) { success }
@@ -205,6 +208,7 @@ async function updateLinearIssueAfterFinalize(options) {
205
208
  prUrl,
206
209
  validationPassed,
207
210
  reviewStateMissing: reviewState === null,
211
+ authoritativeChildCount,
208
212
  });
209
213
  const commentData = await linearGraphQL(`mutation CreateComment($issueId: String!, $body: String!) {
210
214
  commentCreate(input: { issueId: $issueId, body: $body }) { success }
@@ -2,8 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.stepCreatePr = stepCreatePr;
4
4
  const github_js_1 = require("../github.js");
5
- function stepCreatePr(repoRoot, branch, state, draft) {
6
- const prUrl = (0, github_js_1.createDraftPr)({ repoRoot, branch, state, draft });
5
+ function stepCreatePr(repoRoot, branch, state, draft, authoritativeChildCount) {
6
+ const prUrl = (0, github_js_1.createDraftPr)({ repoRoot, branch, state, draft, authoritativeChildCount });
7
7
  console.log(`PR created: ${prUrl}`);
8
8
  return prUrl;
9
9
  }
@@ -2,7 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.stepUpdateLinear = stepUpdateLinear;
4
4
  const linear_js_1 = require("../linear.js");
5
- async function stepUpdateLinear(state, branch, prUrl, validationPassed, linearEnabled, parentIssueId, lifecyclePolicy) {
5
+ async function stepUpdateLinear(state, branch, prUrl, validationPassed, linearEnabled, parentIssueId, lifecyclePolicy, authoritativeChildCount) {
6
6
  if (!linearEnabled) {
7
7
  console.log("[11/12] Linear integration disabled — skipping.");
8
8
  return;
@@ -17,6 +17,6 @@ async function stepUpdateLinear(state, branch, prUrl, validationPassed, linearEn
17
17
  process.stderr.write("Warning: no Linear parent issue ID — skipping Linear update.\n");
18
18
  return;
19
19
  }
20
- await (0, linear_js_1.updateLinearIssueAfterFinalize)({ issueId, state, branch, prUrl, validationPassed, apiKey, lifecyclePolicy });
20
+ await (0, linear_js_1.updateLinearIssueAfterFinalize)({ issueId, state, branch, prUrl, validationPassed, apiKey, lifecyclePolicy, authoritativeChildCount });
21
21
  console.log(`Linear parent ${issueId} updated.`);
22
22
  }