@in-the-loop-labs/pair-review 3.8.0 → 3.9.0

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.
@@ -170,7 +170,8 @@ class AIProvider {
170
170
  * @returns {string} Model ID for extraction
171
171
  */
172
172
  getFastTierModel() {
173
- const models = this.constructor.getModels();
173
+ const overrides = providerConfigOverrides.get(this.constructor.getProviderId());
174
+ const models = applyModelOverrides(this.constructor.getModels(), overrides);
174
175
  const fastModel = models.find(m => m.tier === 'fast');
175
176
  if (fastModel) {
176
177
  return fastModel.id;
@@ -446,17 +447,53 @@ function inferModelDefaults(model) {
446
447
  }
447
448
 
448
449
  /**
449
- * Resolve the default model from an array of model definitions
450
- * Priority: model with default:true > first balanced tier model > first model
451
- * @param {Array<Object>} models - Array of model definitions
450
+ * Match a model definition against a selector that may be the model's canonical
451
+ * id OR one of its aliases. Used by config-driven selectors (`default_model`,
452
+ * `disabled_models`, `models` overrides) so legacy config naming an alias (e.g.
453
+ * `opus`, an alias of the canonical `opus-4.8-xhigh`) keeps working.
454
+ *
455
+ * Optional-chaining is intentional: a model with no `aliases` short-circuits to
456
+ * undefined/falsy, so no Array.isArray guard is needed.
457
+ *
458
+ * @param {Object} model - Model definition (must have an `id`; may have `aliases`)
459
+ * @param {string} selector - Selector to match against id or aliases
460
+ * @returns {boolean}
461
+ */
462
+ function modelMatches(model, selector) {
463
+ return model.id === selector || model.aliases?.includes(selector);
464
+ }
465
+
466
+ /**
467
+ * Resolve the default model from an array of model definitions.
468
+ * Priority: provider-level `default_model` (preferredId) > legacy model with
469
+ * `default:true` > first balanced tier model > first model.
470
+ *
471
+ * `preferredId` is the provider-level `default_model` config value. It is the
472
+ * preferred way to pick a default; the per-model `default:true` flag is the
473
+ * deprecated legacy mechanism, kept for backward compatibility. When
474
+ * `preferredId` names a model that isn't present (e.g. it was disabled or
475
+ * mistyped), resolution falls through to the legacy/automatic logic — the
476
+ * mismatch is warned about once at config-apply time, not here.
477
+ *
478
+ * @param {Array<Object>} models - Array of model definitions (already filtered)
479
+ * @param {string|null} [preferredId] - Provider-level `default_model` id
452
480
  * @returns {string|null} - Default model ID or null if no models
453
481
  */
454
- function resolveDefaultModel(models) {
482
+ function resolveDefaultModel(models, preferredId = null) {
455
483
  if (!models || models.length === 0) {
456
484
  return null;
457
485
  }
458
486
 
459
- // First, look for a model explicitly marked as default
487
+ // Provider-level `default_model` wins when it names an available model
488
+ // (by canonical id or alias). Returns the canonical id either way.
489
+ if (preferredId) {
490
+ const preferred = models.find(m => modelMatches(m, preferredId));
491
+ if (preferred) {
492
+ return preferred.id;
493
+ }
494
+ }
495
+
496
+ // Legacy: model explicitly marked default:true (deprecated in favor of default_model)
460
497
  const explicitDefault = models.find(m => m.default === true);
461
498
  if (explicitDefault) {
462
499
  return explicitDefault.id;
@@ -493,7 +530,7 @@ function createAliasedProviderClass(aliasId, BaseClass, aliasConfig) {
493
530
  AliasedProvider.getProviderId = () => aliasId;
494
531
  if (processedModels) {
495
532
  AliasedProvider.getModels = () => processedModels;
496
- AliasedProvider.getDefaultModel = () => resolveDefaultModel(processedModels);
533
+ AliasedProvider.getDefaultModel = () => resolveDefaultModel(processedModels, aliasConfig.default_model || null);
497
534
  }
498
535
  if (aliasConfig.installInstructions) {
499
536
  AliasedProvider.getInstallInstructions = () => aliasConfig.installInstructions;
@@ -539,7 +576,15 @@ function applyConfigOverrides(config) {
539
576
  const { createExecutableProviderClass } = require('./executable-provider');
540
577
  const ExecClass = createExecutableProviderClass(providerId, providerConfig);
541
578
  registerProvider(providerId, ExecClass);
542
- providerConfigOverrides.set(providerId, { ...providerConfig, models: ExecClass.getModels() });
579
+ const execDisabled = normalizeDisabledModels(providerId, providerConfig.disabled_models);
580
+ const execDefault = providerConfig.default_model || null;
581
+ validateModelSelectors(providerId, ExecClass.getModels(), null, execDisabled, execDefault);
582
+ providerConfigOverrides.set(providerId, {
583
+ ...providerConfig,
584
+ models: ExecClass.getModels(),
585
+ disabled_models: execDisabled,
586
+ default_model: execDefault
587
+ });
543
588
  logger.debug(`Registered executable provider: ${providerId}`);
544
589
  continue;
545
590
  }
@@ -550,6 +595,10 @@ function applyConfigOverrides(config) {
550
595
  const AliasClass = createAliasedProviderClass(providerId, BaseClass, providerConfig);
551
596
  registerProvider(providerId, AliasClass);
552
597
 
598
+ const aliasDisabled = normalizeDisabledModels(providerId, providerConfig.disabled_models);
599
+ const aliasDefault = providerConfig.default_model || null;
600
+ validateModelSelectors(providerId, AliasClass.getModels(), null, aliasDisabled, aliasDefault);
601
+
553
602
  // Aliases reuse the base provider's implementation class, not its config.
554
603
  // Only universal override fields are forwarded; provider-specific fields
555
604
  // (e.g. codex args) must be explicitly set in the alias config.
@@ -561,7 +610,9 @@ function applyConfigOverrides(config) {
561
610
  load_skills: providerConfig.load_skills,
562
611
  app_extensions: providerConfig.app_extensions,
563
612
  availability_timeout_seconds: providerConfig.availability_timeout_seconds,
564
- models: AliasClass.getModels() !== BaseClass.getModels() ? AliasClass.getModels() : null
613
+ models: AliasClass.getModels() !== BaseClass.getModels() ? AliasClass.getModels() : null,
614
+ disabled_models: aliasDisabled,
615
+ default_model: aliasDefault
565
616
  });
566
617
  logger.debug(`Registered aliased provider: ${providerId} (base: ${providerConfig.type})`);
567
618
  continue;
@@ -578,8 +629,36 @@ function applyConfigOverrides(config) {
578
629
  if (Array.isArray(providerConfig.models) && providerConfig.models.length > 0) {
579
630
  processedModels = providerConfig.models.map(inferModelDefaults);
580
631
  logger.debug(`Configured ${processedModels.length} models for ${providerId}`);
632
+ // Deprecation: per-model `default: true` is superseded by provider-level `default_model`.
633
+ // Only warn when the user didn't already adopt the new field.
634
+ if (providerConfig.default_model == null && processedModels.some(m => m.default === true)) {
635
+ logger.warn(`Provider "${providerId}": per-model "default: true" is deprecated. Set provider-level "default_model": "<id>" instead.`);
636
+ }
581
637
  }
582
638
 
639
+ const builtInModels = providerRegistry.get(providerId)?.getModels();
640
+
641
+ // Canonicalize alias-keyed override ids to their built-in canonical id.
642
+ // A config entry may key a model by a short alias (e.g. `opus` for the
643
+ // canonical `opus-4.8-xhigh`). mergeModels() already resolves aliases for the
644
+ // display/metadata path, but the runtime path forwards this raw `models` array
645
+ // to the provider instance, where per-model config is looked up by EXACT id
646
+ // (e.g. `configOverrides.models.find(m => m.id === model)`). The frontend
647
+ // submits the canonical id, so an alias-keyed entry would never match and its
648
+ // cli_model/env/extra_args would be silently dropped. Rewriting the id here —
649
+ // the single point where the override is stored — keeps metadata and runtime
650
+ // execution in agreement.
651
+ if (processedModels && builtInModels) {
652
+ processedModels = processedModels.map(cm => {
653
+ const builtIn = builtInModels.find(bm => modelMatches(bm, cm.id));
654
+ return builtIn && builtIn.id !== cm.id ? { ...cm, id: builtIn.id } : cm;
655
+ });
656
+ }
657
+
658
+ const disabledModels = normalizeDisabledModels(providerId, providerConfig.disabled_models);
659
+ const defaultModel = providerConfig.default_model || null;
660
+ validateModelSelectors(providerId, builtInModels, processedModels, disabledModels, defaultModel);
661
+
583
662
  // Store the overrides
584
663
  providerConfigOverrides.set(providerId, {
585
664
  command: providerConfig.command,
@@ -589,7 +668,9 @@ function applyConfigOverrides(config) {
589
668
  load_skills: providerConfig.load_skills,
590
669
  app_extensions: providerConfig.app_extensions,
591
670
  availability_timeout_seconds: providerConfig.availability_timeout_seconds,
592
- models: processedModels
671
+ models: processedModels,
672
+ disabled_models: disabledModels,
673
+ default_model: defaultModel
593
674
  });
594
675
  }
595
676
 
@@ -653,8 +734,12 @@ function resolveNonExecutableProviderId(preferredId) {
653
734
 
654
735
  /**
655
736
  * Merge config-override models with a provider's built-in models.
656
- * Config models with matching IDs replace built-ins; config models with new IDs
657
- * are appended. If no config models exist, returns built-ins unchanged.
737
+ * A config model whose id matches a built-in's canonical id OR one of its
738
+ * aliases replaces that built-in in place (preserving display order). The
739
+ * canonical built-in id is preserved as the internal source of truth, and the
740
+ * built-in's aliases are kept unless the override supplies its own. Config
741
+ * models that match no built-in are appended. If no config models exist,
742
+ * returns built-ins unchanged.
658
743
  *
659
744
  * @param {Array<Object>} builtInModels - Models from ProviderClass.getModels()
660
745
  * @param {Array<Object>|undefined} configModels - Models from config overrides
@@ -664,13 +749,114 @@ function mergeModels(builtInModels, configModels) {
664
749
  if (!configModels || configModels.length === 0) {
665
750
  return builtInModels;
666
751
  }
667
- const configById = new Map(configModels.map(m => [m.id, m]));
668
- // Replace overridden built-ins in-place to preserve display order
669
- const merged = builtInModels.map(m => configById.get(m.id) || m);
670
- // Append config models with new IDs not present in built-ins
671
- const builtInIds = new Set(builtInModels.map(m => m.id));
672
- const newModels = configModels.filter(m => !builtInIds.has(m.id));
673
- return [...merged, ...newModels];
752
+ // Replace overridden built-ins in-place to preserve display order. An override
753
+ // matched by alias (e.g. config `{ id: 'opus' }` against built-in
754
+ // `opus-4.8-xhigh`) still replaces, but keeps the canonical built-in id.
755
+ const matched = new Set();
756
+ const merged = builtInModels.map(bm => {
757
+ const override = configModels.find(cm => modelMatches(bm, cm.id));
758
+ if (override) {
759
+ matched.add(override);
760
+ return { ...override, id: bm.id, aliases: override.aliases ?? bm.aliases };
761
+ }
762
+ return bm;
763
+ });
764
+ // Append config models that matched no built-in (genuinely new ids)
765
+ for (const cm of configModels) {
766
+ if (!matched.has(cm)) {
767
+ merged.push(cm);
768
+ }
769
+ }
770
+ return merged;
771
+ }
772
+
773
+ /**
774
+ * Compute the effective model list for a provider: built-in models merged with
775
+ * config-override models, then with any `disabled_models` IDs removed.
776
+ *
777
+ * This is the single source of truth for "which models does this provider
778
+ * actually expose" — every call site that surfaces or selects a model should go
779
+ * through here so a disabled model is hidden consistently (UI, default
780
+ * resolution, instance creation).
781
+ *
782
+ * If `disabled_models` would remove every model, the filter is ignored (a
783
+ * provider with zero models is unusable). Unknown/empty-list validation and
784
+ * warnings happen once at config-apply time in {@link applyConfigOverrides};
785
+ * this function is intentionally silent so it can run on every request.
786
+ *
787
+ * @param {Array<Object>} builtInModels - Models from ProviderClass.getModels()
788
+ * @param {Object} [overrides] - Stored config overrides for the provider
789
+ * @returns {Array<Object>} Effective model list
790
+ */
791
+ function applyModelOverrides(builtInModels, overrides) {
792
+ const merged = mergeModels(builtInModels, overrides?.models);
793
+ const disabled = overrides?.disabled_models;
794
+ if (!Array.isArray(disabled) || disabled.length === 0) {
795
+ return merged;
796
+ }
797
+ // Drop a model if ANY disabled selector matches it by canonical id or alias.
798
+ const filtered = merged.filter(m => !disabled.some(d => modelMatches(m, d)));
799
+ // Never strip a provider down to zero models — fall back to the unfiltered set.
800
+ return filtered.length > 0 ? filtered : merged;
801
+ }
802
+
803
+ /**
804
+ * Normalize a `disabled_models` config value into a clean array of string IDs
805
+ * (or null when absent/empty). Warns on malformed input.
806
+ * @param {string} providerId - Provider ID (for log messages)
807
+ * @param {*} raw - Raw config value
808
+ * @returns {string[]|null}
809
+ */
810
+ function normalizeDisabledModels(providerId, raw) {
811
+ if (raw == null) {
812
+ return null;
813
+ }
814
+ if (!Array.isArray(raw)) {
815
+ logger.warn(`Provider "${providerId}": "disabled_models" must be an array of model IDs; ignoring.`);
816
+ return null;
817
+ }
818
+ const ids = raw.filter(id => typeof id === 'string' && id.length > 0);
819
+ if (ids.length !== raw.length) {
820
+ logger.warn(`Provider "${providerId}": "disabled_models" contained non-string entries which were ignored.`);
821
+ }
822
+ return ids.length > 0 ? ids : null;
823
+ }
824
+
825
+ /**
826
+ * Validate provider-level model selectors against the effective model set and
827
+ * warn about mistakes. Never throws — bad selectors degrade gracefully at
828
+ * resolution time (a disabled-everything list is ignored, an unknown
829
+ * default_model falls back to automatic selection).
830
+ * @param {string} providerId - Provider ID (for log messages)
831
+ * @param {Array<Object>} builtInModels - Provider's built-in models
832
+ * @param {Array<Object>|null} configModels - Processed config-override models
833
+ * @param {string[]|null} disabledModels - Normalized disabled list
834
+ * @param {string|null} defaultModel - Provider-level default_model id
835
+ */
836
+ function validateModelSelectors(providerId, builtInModels, configModels, disabledModels, defaultModel) {
837
+ const merged = mergeModels(builtInModels || [], configModels);
838
+ // A selector is "known" if it matches some model by canonical id OR alias.
839
+ const isKnown = (sel) => merged.some(m => modelMatches(m, sel));
840
+
841
+ if (disabledModels) {
842
+ for (const id of disabledModels) {
843
+ if (!isKnown(id)) {
844
+ logger.warn(`Provider "${providerId}": disabled_models references unknown model "${id}".`);
845
+ }
846
+ }
847
+ const remaining = merged.filter(m => !disabledModels.some(d => modelMatches(m, d)));
848
+ if (merged.length > 0 && remaining.length === 0) {
849
+ logger.warn(`Provider "${providerId}": disabled_models removes every model; the filter will be ignored.`);
850
+ }
851
+ }
852
+
853
+ if (defaultModel != null) {
854
+ if (!isKnown(defaultModel)) {
855
+ logger.warn(`Provider "${providerId}": default_model "${defaultModel}" is not a known model; falling back to automatic default.`);
856
+ } else if (disabledModels && disabledModels.includes(defaultModel)) {
857
+ logger.warn(`Provider "${providerId}": default_model "${defaultModel}" is also listed in disabled_models; falling back to automatic default.`);
858
+ }
859
+ }
674
860
  }
675
861
 
676
862
  /**
@@ -683,11 +869,17 @@ function getAllProvidersInfo() {
683
869
  for (const [id, ProviderClass] of providerRegistry) {
684
870
  const overrides = providerConfigOverrides.get(id);
685
871
 
686
- // Merge config models with built-in models (config wins on ID collision)
687
- const models = mergeModels(ProviderClass.getModels(), overrides?.models);
872
+ // Effective models: config merged with built-ins, minus disabled_models
873
+ const effectiveModels = applyModelOverrides(ProviderClass.getModels(), overrides);
874
+
875
+ // Resolve default model: provider-level default_model wins, then legacy/auto
876
+ const defaultModel = resolveDefaultModel(effectiveModels, overrides?.default_model) || ProviderClass.getDefaultModel();
688
877
 
689
- // Resolve default model from merged models array
690
- const defaultModel = resolveDefaultModel(models) || ProviderClass.getDefaultModel();
878
+ // Normalize per-model `default` flags to agree with the resolved defaultModel.
879
+ // Many frontend consumers derive the default via models.find(m => m.default),
880
+ // so the payload's flags must reflect the new source of truth (default_model).
881
+ // Produce NEW objects — never mutate the shared built-in model objects.
882
+ const models = effectiveModels.map(m => ({ ...m, default: m.id === defaultModel }));
691
883
 
692
884
  // Use overridden install instructions if available
693
885
  const installInstructions = overrides?.installInstructions || ProviderClass.getInstallInstructions();
@@ -736,11 +928,13 @@ function createProvider(providerId, model = null, overrides = {}) {
736
928
  // Determine the actual model to use
737
929
  let actualModel = model;
738
930
  if (!actualModel) {
739
- // Resolve default from merged models (config + built-in).
931
+ // Resolve default from effective models (config + built-in, minus disabled).
740
932
  // Checks both sources because some providers (e.g., Pi) define built-in
741
- // modes with default:true that aren't in config overrides.
933
+ // modes with default:true that aren't in config overrides. Honors the
934
+ // provider-level `default_model` selector.
742
935
  if (configOverrides?.models || ProviderClass.getModels().length > 0) {
743
- actualModel = resolveDefaultModel(mergeModels(ProviderClass.getModels(), configOverrides?.models));
936
+ const effectiveModels = applyModelOverrides(ProviderClass.getModels(), configOverrides);
937
+ actualModel = resolveDefaultModel(effectiveModels, configOverrides?.default_model);
744
938
  }
745
939
  // Fall back to provider's built-in default
746
940
  if (!actualModel) {
@@ -831,7 +1025,7 @@ function getTierForModel(providerId, modelId) {
831
1025
  const overrides = providerConfigOverrides.get(providerId);
832
1026
  const models = mergeModels(ProviderClass.getModels(), overrides?.models);
833
1027
 
834
- const model = models.find(m => m.id === modelId || m.aliases?.includes(modelId));
1028
+ const model = models.find(m => modelMatches(m, modelId));
835
1029
  return model?.tier || null;
836
1030
  }
837
1031
 
@@ -855,6 +1049,10 @@ module.exports = {
855
1049
  getProviderConfigOverrides,
856
1050
  inferModelDefaults,
857
1051
  resolveDefaultModel,
1052
+ modelMatches,
1053
+ mergeModels,
1054
+ applyModelOverrides,
1055
+ normalizeDisabledModels,
858
1056
  prettifyModelId,
859
1057
  getTierForModel
860
1058
  };
@@ -36,10 +36,17 @@ const logger = require('../utils/logger');
36
36
  * @param {Object} params.councilConfig - The council's parsed config object
37
37
  * @param {string} params.worktreePath - Path to the checked-out worktree
38
38
  * @param {Object} [params.prMetadata] - PR metadata (head_sha used for the run row)
39
+ * @param {Array<Object|string>|null} [params.changedFiles] - Precomputed scope-aware
40
+ * changed-file list (local mode) so council validation uses the same file set as
41
+ * the generated diff; `null` in PR mode (the analyzer derives it from Git). Mirrors
42
+ * the route contract (`src/routes/local.js` passes the list, `src/routes/pr.js`
43
+ * passes `null`).
39
44
  * @param {Object} params.instructions - { globalInstructions, repoInstructions, requestInstructions }
40
45
  * (requestInstructions may be null)
41
46
  * @param {Object} [params.githubClient] - GitHub client passed through to the analyzer
42
- * @returns {Promise<Object>} The analyzer result ({ suggestions, summary, levelOutcomes, ... })
47
+ * @returns {Promise<Object>} `{ runId, ...result }` the parent run id created
48
+ * here, spread together with the analyzer result
49
+ * ({ suggestions, summary, levelOutcomes, ... }).
43
50
  */
44
51
  async function runHeadlessCouncilAnalysis(db, params) {
45
52
  const {
@@ -50,6 +57,7 @@ async function runHeadlessCouncilAnalysis(db, params) {
50
57
  councilConfig,
51
58
  worktreePath,
52
59
  prMetadata,
60
+ changedFiles,
53
61
  instructions,
54
62
  githubClient,
55
63
  } = params;
@@ -90,7 +98,9 @@ async function runHeadlessCouncilAnalysis(db, params) {
90
98
  reviewId,
91
99
  worktreePath,
92
100
  prMetadata,
93
- changedFiles: null,
101
+ // Local headless runs forward a scope-aware list; PR mode passes null and
102
+ // the analyzer derives the file set from Git (matches src/routes/pr.js).
103
+ changedFiles: changedFiles || null,
94
104
  instructions
95
105
  };
96
106
 
@@ -108,7 +118,7 @@ async function runHeadlessCouncilAnalysis(db, params) {
108
118
  logger.warn(`Failed to update analysis_run: ${err.message}`);
109
119
  });
110
120
 
111
- return result;
121
+ return { runId, ...result };
112
122
  } catch (error) {
113
123
  await runRepo.update(runId, { status: 'failed' }).catch(() => {});
114
124
  throw error;
package/src/database.js CHANGED
@@ -3679,6 +3679,58 @@ class CommentRepository {
3679
3679
  `, [reviewId]);
3680
3680
  }
3681
3681
 
3682
+ /**
3683
+ * Get the consolidated final AI suggestions for a single analysis run.
3684
+ *
3685
+ * This returns the run-scoped, orchestrated (consolidated) layer: the final
3686
+ * suggestions the app shows by default for a run — `source='ai'`,
3687
+ * `ai_level IS NULL` (not per-level), non-raw, and (by default) not dismissed.
3688
+ * It is the shared retrieval used by both the MCP `get_ai_suggestions` tool
3689
+ * and the CLI JSON output for agents.
3690
+ *
3691
+ * INTENTIONALLY DISTINCT from the review-scoped submit query in
3692
+ * `performHeadlessReview` (src/main.js ~1195-1207), which must NOT be migrated
3693
+ * to this method. That query has different semantics on purpose:
3694
+ * - review-scoped (latest-review-wide), not single-run-scoped;
3695
+ * - `status = 'active'` only (no adopted);
3696
+ * - thin columns tailored for GitHub submission.
3697
+ * Keep the two queries separate.
3698
+ *
3699
+ * @param {string} runId - Analysis run ID (`ai_run_id`)
3700
+ * @param {Object} [options] - Query options
3701
+ * @param {string[]} [options.statuses=['active','adopted']] - Statuses to include
3702
+ * @param {string|null} [options.file=null] - Restrict to a single file path
3703
+ * @returns {Promise<Array<Object>>} Consolidated final suggestion rows
3704
+ */
3705
+ async getFinalSuggestionsByRunId(runId, { statuses = ['active', 'adopted'], file = null } = {}) {
3706
+ const params = [runId];
3707
+ const conditions = [
3708
+ 'ai_run_id = ?',
3709
+ "source = 'ai'",
3710
+ 'ai_level IS NULL',
3711
+ '(is_raw = 0 OR is_raw IS NULL)'
3712
+ ];
3713
+
3714
+ const placeholders = statuses.map(() => '?').join(', ');
3715
+ conditions.push(`status IN (${placeholders})`);
3716
+ params.push(...statuses);
3717
+
3718
+ if (file) {
3719
+ conditions.push('file = ?');
3720
+ params.push(file);
3721
+ }
3722
+
3723
+ return await query(this.db, `
3724
+ SELECT
3725
+ id, ai_run_id, ai_level, ai_confidence,
3726
+ file, line_start, line_end, type, title, body,
3727
+ reasoning, status, is_file_level, severity, created_at
3728
+ FROM comments
3729
+ WHERE ${conditions.join('\n AND ')}
3730
+ ORDER BY file, line_start
3731
+ `, params);
3732
+ }
3733
+
3682
3734
  /**
3683
3735
  * Restore a soft-deleted user comment (set status from 'inactive' back to 'active')
3684
3736
  * @param {number} id - Comment ID
@@ -423,7 +423,13 @@ class GitWorktreeManager {
423
423
  child.stdout.on('data', (data) => {
424
424
  const chunk = data.toString();
425
425
  stdout += chunk;
426
- process.stdout.write(chunk);
426
+ // When PAIR_REVIEW_QUIET_STDOUT is set (headless --json or MCP stdio
427
+ // mode, via redirectConsoleToStderr in src/mcp-stdio.js), stdout is
428
+ // reserved for a machine-readable document (JSON / JSON-RPC). Mirror the
429
+ // child's stdout to stderr in that case so it doesn't corrupt the
430
+ // reserved stream; otherwise mirror it to stdout as before.
431
+ const sink = process.env.PAIR_REVIEW_QUIET_STDOUT ? process.stderr : process.stdout;
432
+ sink.write(chunk);
427
433
  });
428
434
  child.stderr.on('data', (data) => {
429
435
  const chunk = data.toString();
@@ -0,0 +1,152 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Shared helpers for threading per-run CLI instructions into analysis.
4
+ *
5
+ * `resolveCliInstructions` reads `--instructions` / `--instructions-file`.
6
+ *
7
+ * `buildInteractiveAnalysisConfig` / `prepareInteractiveAnalysisConfig` bridge the
8
+ * CLI to the browser-side auto-analyze: interactive `--ai` / `--council` runs open
9
+ * the web UI and trigger analysis via the `?analyze=true` URL, which has no slot
10
+ * for custom instructions. To honor `--instructions` there too (instead of
11
+ * silently dropping it), the CLI resolves the full review config + instructions
12
+ * and threads a short id through the URL as `analysisConfigId`. The PR/local
13
+ * browser code already resolves that id before starting analysis (see
14
+ * `_fetchAutoAnalysisConfigFromUrl` in public/js/pr.js and the local auto-analyze
15
+ * in public/js/local.js).
16
+ *
17
+ * The resolution is split so the same config object serves two storage targets:
18
+ * - `buildInteractiveAnalysisConfig` — PURE, returns the resolved config object
19
+ * (no storage). Reused by the delegation path, which POSTs it to a server
20
+ * running in ANOTHER process (the in-memory store below is per-process).
21
+ * - `prepareInteractiveAnalysisConfig` — the cold-start wrapper that stashes the
22
+ * built config in the in-memory bulk-analysis-config store (same process that
23
+ * then starts the server) and returns the id.
24
+ *
25
+ * Living in its own module keeps both CLI entry points — `handlePullRequest`
26
+ * (src/main.js) and `handleLocalReview` (src/local-review.js) — able to import
27
+ * it without a require cycle through main.js.
28
+ */
29
+
30
+ const fs = require('fs');
31
+ const { resolveReviewConfig } = require('./review-config');
32
+ const { createBulkAnalysisConfig } = require('./routes/bulk-analysis-configs');
33
+
34
+ const MAX_INSTRUCTIONS_CHARS = 5000;
35
+
36
+ /**
37
+ * Resolve per-run custom instructions ("requestInstructions") from CLI flags.
38
+ *
39
+ * Precedence: `--instructions <text>` is used directly; otherwise
40
+ * `--instructions-file <path>` is read from disk. The result is trimmed and
41
+ * capped at 5000 chars (parity with the web analyze routes). Returns `null`
42
+ * when neither flag is supplied.
43
+ *
44
+ * @param {Object} flags - Parsed CLI flags
45
+ * @returns {Promise<string|null>}
46
+ * @throws {Error} On unreadable instructions file or when the text exceeds the cap.
47
+ */
48
+ async function resolveCliInstructions(flags) {
49
+ let text = null;
50
+
51
+ if (typeof flags.instructions === 'string') {
52
+ text = flags.instructions;
53
+ } else if (typeof flags.instructionsFile === 'string') {
54
+ try {
55
+ text = await fs.promises.readFile(flags.instructionsFile, 'utf8');
56
+ } catch (err) {
57
+ throw new Error(`Failed to read --instructions-file "${flags.instructionsFile}": ${err.message}`);
58
+ }
59
+ }
60
+
61
+ if (text == null) return null;
62
+
63
+ text = text.trim();
64
+ if (text.length === 0) return null;
65
+
66
+ if (text.length > MAX_INSTRUCTIONS_CHARS) {
67
+ throw new Error(
68
+ `Custom instructions exceed the ${MAX_INSTRUCTIONS_CHARS}-character limit (got ${text.length}).`
69
+ );
70
+ }
71
+
72
+ return text;
73
+ }
74
+
75
+ /**
76
+ * For an interactive `--ai` / `--council` run that ALSO carries
77
+ * `--instructions[-file]`, resolve the full review config (single provider/model
78
+ * or council) plus the instruction text into the analysis-config object the
79
+ * browser-side analyze code consumes. PURE: it performs no storage, so it serves
80
+ * both the in-process cold-start path (via `prepareInteractiveAnalysisConfig`)
81
+ * and the cross-process delegation path (which POSTs the object to the running
82
+ * server — see `storeAnalysisConfigRemote` in src/single-port.js).
83
+ *
84
+ * Returns `null` when no instructions were supplied, so callers keep their prior
85
+ * URL shape (bare `?analyze=true` + optional `&council=`) unchanged.
86
+ *
87
+ * The returned config is shape-compatible with what the browser-side analyze code
88
+ * consumes: a single pick becomes `{ provider, model, customInstructions }`; a
89
+ * council becomes an inline snapshot `{ isCouncil, configType, councilConfig,
90
+ * councilName, customInstructions }` (the snapshot forces the analysis route to
91
+ * use the exact resolved council rather than re-fetching by id).
92
+ *
93
+ * @param {Object} params
94
+ * @param {Object} params.db - Database instance
95
+ * @param {Object} params.config - Loaded global config
96
+ * @param {Object} params.flags - Parsed CLI flags (council/model/instructions)
97
+ * @param {string} params.repository - owner/repo (for repo-default resolution)
98
+ * @returns {Promise<Object|null>} The analysisConfig object, or null when no instructions.
99
+ */
100
+ async function buildInteractiveAnalysisConfig({ db, config, flags, repository }) {
101
+ const requestInstructions = await resolveCliInstructions(flags);
102
+ if (!requestInstructions) return null;
103
+
104
+ const reviewConfig = await resolveReviewConfig(
105
+ db,
106
+ repository,
107
+ { council: flags.council, model: flags.model },
108
+ config
109
+ );
110
+
111
+ return reviewConfig.type === 'council'
112
+ ? {
113
+ isCouncil: true,
114
+ configType: reviewConfig.configType,
115
+ councilConfig: reviewConfig.councilConfig,
116
+ councilName: reviewConfig.council.name || null,
117
+ customInstructions: requestInstructions
118
+ }
119
+ : {
120
+ provider: reviewConfig.provider,
121
+ model: reviewConfig.model,
122
+ customInstructions: requestInstructions
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Thin in-process wrapper around `buildInteractiveAnalysisConfig`: resolve the
128
+ * analysis config and, when present, stash it in the in-memory
129
+ * bulk-analysis-config store, returning the id to thread through the browser URL
130
+ * as `analysisConfigId`. Used by the cold-start handlers (`handlePullRequest`,
131
+ * `handleLocalReview`) that go on to start the server in THIS process, so the
132
+ * in-process store is the one the browser tab will read from.
133
+ *
134
+ * Returns `null` when no instructions were supplied (callers keep their prior URL
135
+ * shape unchanged).
136
+ *
137
+ * @param {Object} params - See `buildInteractiveAnalysisConfig`.
138
+ * @returns {Promise<string|null>} The analysisConfigId, or null when no instructions.
139
+ */
140
+ async function prepareInteractiveAnalysisConfig({ db, config, flags, repository }) {
141
+ const analysisConfig = await buildInteractiveAnalysisConfig({ db, config, flags, repository });
142
+ if (!analysisConfig) return null;
143
+
144
+ const { id } = createBulkAnalysisConfig(analysisConfig);
145
+ return id;
146
+ }
147
+
148
+ module.exports = {
149
+ resolveCliInstructions,
150
+ buildInteractiveAnalysisConfig,
151
+ prepareInteractiveAnalysisConfig
152
+ };