@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.
- package/README.md +175 -3
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/js/local.js +27 -8
- package/public/js/utils/provider-model.js +9 -2
- package/src/ai/claude-provider.js +27 -27
- package/src/ai/index.js +8 -0
- package/src/ai/provider.js +225 -27
- package/src/councils/headless-council.js +13 -3
- package/src/database.js +52 -0
- package/src/git/worktree.js +7 -1
- package/src/interactive-analysis-config.js +152 -0
- package/src/local-review.js +43 -21
- package/src/main.js +951 -45
- package/src/mcp-stdio.js +40 -5
- package/src/review-config.js +164 -0
- package/src/routes/bulk-analysis-configs.js +45 -15
- package/src/routes/config.js +9 -4
- package/src/routes/executable-analysis.js +10 -1
- package/src/routes/local.js +170 -109
- package/src/routes/mcp.js +28 -31
- package/src/routes/pr.js +118 -56
- package/src/single-port.js +116 -6
- package/src/utils/logger.js +25 -4
package/src/ai/provider.js
CHANGED
|
@@ -170,7 +170,8 @@ class AIProvider {
|
|
|
170
170
|
* @returns {string} Model ID for extraction
|
|
171
171
|
*/
|
|
172
172
|
getFastTierModel() {
|
|
173
|
-
const
|
|
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
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
*
|
|
657
|
-
*
|
|
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
|
-
|
|
668
|
-
//
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
const
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
//
|
|
687
|
-
const
|
|
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
|
-
//
|
|
690
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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>}
|
|
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
|
-
|
|
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
|
package/src/git/worktree.js
CHANGED
|
@@ -423,7 +423,13 @@ class GitWorktreeManager {
|
|
|
423
423
|
child.stdout.on('data', (data) => {
|
|
424
424
|
const chunk = data.toString();
|
|
425
425
|
stdout += chunk;
|
|
426
|
-
|
|
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
|
+
};
|