@in-the-loop-labs/pair-review 3.8.0 → 3.9.1
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/css/pr.css +12 -60
- package/public/js/components/CouncilProgressModal.js +0 -10
- package/public/js/local.js +27 -57
- package/public/js/pr.js +0 -114
- package/public/js/utils/provider-model.js +9 -2
- package/public/local.html +0 -7
- package/public/pr.html +0 -7
- package/src/ai/claude-provider.js +56 -34
- 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 +953 -46
- 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
|
@@ -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
|
+
};
|
package/src/local-review.js
CHANGED
|
@@ -14,6 +14,7 @@ const execAsync = promisify(exec);
|
|
|
14
14
|
const { STOPS, scopeIncludes, includesBranch, DEFAULT_SCOPE, scopeLabel, reviewScope } = require('./local-scope');
|
|
15
15
|
const { initializeDatabase, ReviewRepository, RepoSettingsRepository } = require('./database');
|
|
16
16
|
const { resolveCouncilHandle } = require('./councils/resolve-council');
|
|
17
|
+
const { prepareInteractiveAnalysisConfig } = require('./interactive-analysis-config');
|
|
17
18
|
const { startServer } = require('./server');
|
|
18
19
|
const { localReviewDiffs } = require('./routes/shared');
|
|
19
20
|
const summaryGenerator = require('./ai/summary-generator');
|
|
@@ -703,9 +704,14 @@ async function generateLocalDiff(repoPath, options = {}) {
|
|
|
703
704
|
* @param {Object} params.config - Loaded config
|
|
704
705
|
* @param {string} params.repoPath - Path to the working tree (already validated)
|
|
705
706
|
* @param {Object} [params.flags] - CLI flags / options
|
|
707
|
+
* @param {boolean} [params.startBackgroundJobs=true] - When false, skip the
|
|
708
|
+
* fire-and-forget summary/tour generation jobs. The interactive UI wants them
|
|
709
|
+
* (they populate the review while the human reads the diff), but a one-shot
|
|
710
|
+
* headless run does not: they would spend provider budget after the JSON
|
|
711
|
+
* result is already printed and keep the event loop alive (CLI hang).
|
|
706
712
|
* @returns {Promise<{sessionId: number, repoPath: string, branch: string, repository: string, headSha: string, diff: string, stats: Object, digest: string|null, branchInfo: Object|null, scopeStart: string, scopeEnd: string, baseBranch: string|null}>}
|
|
707
713
|
*/
|
|
708
|
-
async function setupLocalReviewSession({ db, config, repoPath, flags = {} }) {
|
|
714
|
+
async function setupLocalReviewSession({ db, config, repoPath, flags = {}, startBackgroundJobs = true }) {
|
|
709
715
|
const headSha = await module.exports.getHeadSha(repoPath);
|
|
710
716
|
console.log(`HEAD SHA: ${headSha}`);
|
|
711
717
|
|
|
@@ -837,25 +843,30 @@ async function setupLocalReviewSession({ db, config, repoPath, flags = {} }) {
|
|
|
837
843
|
logger.warn(`Could not persist diff to database: ${persistError.message}`);
|
|
838
844
|
}
|
|
839
845
|
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
846
|
+
// Fire-and-forget summary/tour generation. Skipped for headless one-shot runs
|
|
847
|
+
// (startBackgroundJobs:false) — they would spend provider budget after the
|
|
848
|
+
// result is reported and keep the event loop alive, hanging the CLI.
|
|
849
|
+
if (startBackgroundJobs) {
|
|
850
|
+
summaryGenerator.kickOffSummaryJob({
|
|
851
|
+
db,
|
|
852
|
+
config,
|
|
853
|
+
reviewId: sessionId,
|
|
854
|
+
diffText: diff,
|
|
855
|
+
worktreePath: repoPath,
|
|
856
|
+
reviewContext: { prTitle: branch },
|
|
857
|
+
trigger: 'auto'
|
|
858
|
+
})?.catch((err) => logger.warn(`Hunk summary job failed for review ${sessionId}: ${err.message}`));
|
|
859
|
+
|
|
860
|
+
tourGenerator.kickOffTourJob({
|
|
861
|
+
db,
|
|
862
|
+
config,
|
|
863
|
+
reviewId: sessionId,
|
|
864
|
+
diffText: diff,
|
|
865
|
+
worktreePath: repoPath,
|
|
866
|
+
reviewContext: { prTitle: branch },
|
|
867
|
+
trigger: 'auto'
|
|
868
|
+
})?.catch((err) => logger.warn(`Tour job failed for review ${sessionId}: ${err.message}`));
|
|
869
|
+
}
|
|
859
870
|
|
|
860
871
|
return {
|
|
861
872
|
sessionId,
|
|
@@ -928,7 +939,18 @@ async function handleLocalReview(targetPath, flags = {}) {
|
|
|
928
939
|
let url = `http://localhost:${port}/local/${session.sessionId}`;
|
|
929
940
|
const shouldAnalyze = flags.ai || flags.council;
|
|
930
941
|
if (shouldAnalyze) url += '?analyze=true';
|
|
931
|
-
|
|
942
|
+
// When the run carries --instructions, stash the resolved analysis config and
|
|
943
|
+
// thread its id so the browser-side auto-analyze honors the instructions
|
|
944
|
+
// instead of silently dropping them (mirrors handlePullRequest). The id
|
|
945
|
+
// encodes the council selection, so prefer it over the bare &council= param.
|
|
946
|
+
const analysisConfigId = shouldAnalyze
|
|
947
|
+
? await prepareInteractiveAnalysisConfig({ db, config, flags, repository: session.repository })
|
|
948
|
+
: null;
|
|
949
|
+
if (analysisConfigId) {
|
|
950
|
+
url += `&analysisConfigId=${analysisConfigId}`;
|
|
951
|
+
} else if (councilSelection) {
|
|
952
|
+
url += `&council=${councilSelection.id}`;
|
|
953
|
+
}
|
|
932
954
|
console.log(`\nOpening browser to: ${url}`);
|
|
933
955
|
await open(url);
|
|
934
956
|
|