@open-cr-agent/cli 0.1.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/LICENSE +202 -0
- package/README.md +43 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.js +4 -0
- package/dist/memory.d.ts +4 -0
- package/dist/memory.js +116 -0
- package/dist/review/args.d.ts +24 -0
- package/dist/review/args.js +141 -0
- package/dist/review/command.d.ts +27 -0
- package/dist/review/command.js +185 -0
- package/dist/review/config.d.ts +50 -0
- package/dist/review/config.js +125 -0
- package/dist/review/plan-render.d.ts +3 -0
- package/dist/review/plan-render.js +36 -0
- package/dist/review/plugins.d.ts +3 -0
- package/dist/review/plugins.js +32 -0
- package/dist/review/progress.d.ts +20 -0
- package/dist/review/progress.js +69 -0
- package/dist/review/remote-config.d.ts +27 -0
- package/dist/review/remote-config.js +90 -0
- package/dist/review/render.d.ts +5 -0
- package/dist/review/render.js +152 -0
- package/dist/review/target.d.ts +15 -0
- package/dist/review/target.js +89 -0
- package/dist/review/terminal.d.ts +2 -0
- package/dist/review/terminal.js +12 -0
- package/dist/run.d.ts +6 -0
- package/dist/run.js +101 -0
- package/package.json +47 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { join, relative, resolve } from "node:path";
|
|
2
|
+
import { agentsMdReviewerPlugin, correctnessReviewerPlugin, coverageGaps, defaultSelectionPolicy, docsReviewerPlugin, newSessionId, performanceReviewerPlugin, previewReview, runReview, securityReviewerPlugin, sessionJsonlPlugin, startPlugins, toPlanOutput, } from "@open-cr-agent/core";
|
|
3
|
+
import { opencodeRuntimePlugin } from "@open-cr-agent/runtime-opencode";
|
|
4
|
+
import { githubPlugin } from "@open-cr-agent/vcs-github";
|
|
5
|
+
import { findRepositoryRoot, localGitPlugin } from "@open-cr-agent/vcs-local";
|
|
6
|
+
import { ConfigError } from "./config.js";
|
|
7
|
+
import { renderPlan } from "./plan-render.js";
|
|
8
|
+
import { ProgressPrinter } from "./progress.js";
|
|
9
|
+
import { renderJson, renderText, safeJson } from "./render.js";
|
|
10
|
+
import { localTarget, pullRequestTarget } from "./target.js";
|
|
11
|
+
import { forTerminal } from "./terminal.js";
|
|
12
|
+
export const SESSIONS_DIR = ".ocra/sessions";
|
|
13
|
+
export const EXIT = { ok: 0, blocking: 1, error: 2, incomplete: 3, interrupted: 130 };
|
|
14
|
+
export const BUILTIN_PLUGINS = [
|
|
15
|
+
localGitPlugin,
|
|
16
|
+
githubPlugin,
|
|
17
|
+
opencodeRuntimePlugin,
|
|
18
|
+
correctnessReviewerPlugin,
|
|
19
|
+
securityReviewerPlugin,
|
|
20
|
+
performanceReviewerPlugin,
|
|
21
|
+
docsReviewerPlugin,
|
|
22
|
+
agentsMdReviewerPlugin,
|
|
23
|
+
sessionJsonlPlugin,
|
|
24
|
+
];
|
|
25
|
+
export async function reviewCommand(args, io, deps) {
|
|
26
|
+
const root = await findRepositoryRoot(deps.cwd);
|
|
27
|
+
const warn = (message) => io.err.write(`[ocra] Warning: ${forTerminal(message)}\n`);
|
|
28
|
+
const target = args.pullRequest
|
|
29
|
+
? await pullRequestTarget(args.pullRequest, deps.cwd, root, deps.env, warn, deps.fetch, args.ignoreRepoConfig === true)
|
|
30
|
+
: await localTarget(args, deps.cwd, root, deps.env, warn, deps.fetch);
|
|
31
|
+
const { config } = target;
|
|
32
|
+
const session = { dir: join(root, SESSIONS_DIR), id: newSessionId() };
|
|
33
|
+
// A plan calls no model and writes no session log.
|
|
34
|
+
const builtins = args.plan
|
|
35
|
+
? deps.builtinPlugins.filter((p) => p.name !== sessionJsonlPlugin.name)
|
|
36
|
+
: deps.builtinPlugins;
|
|
37
|
+
const registry = await startPlugins([...builtins, ...target.plugins], {
|
|
38
|
+
settings: args.plan
|
|
39
|
+
? config.pluginSettings
|
|
40
|
+
: { ...config.pluginSettings, [sessionJsonlPlugin.name]: session },
|
|
41
|
+
env: deps.env,
|
|
42
|
+
warn,
|
|
43
|
+
});
|
|
44
|
+
const vcs = target.createVcs(registry);
|
|
45
|
+
const overrides = reviewerOverrides(config, args, registry.reviewers.map((r) => r.id));
|
|
46
|
+
if (args.plan) {
|
|
47
|
+
const preview = await previewReview({
|
|
48
|
+
vcs,
|
|
49
|
+
reviewers: registry.reviewers,
|
|
50
|
+
reviewerOverrides: overrides,
|
|
51
|
+
rules: [...registry.rules, ...config.rules],
|
|
52
|
+
selection: { ...defaultSelectionPolicy, include: config.include, exclude: config.exclude },
|
|
53
|
+
...(target.readTrusted ? { readTrusted: target.readTrusted } : {}),
|
|
54
|
+
...(args.ultra ? { ultra: true } : {}),
|
|
55
|
+
...(config.maxTasks !== undefined ? { maxTasks: config.maxTasks } : {}),
|
|
56
|
+
});
|
|
57
|
+
const rendered = args.format === "json" ? `${safeJson(toPlanOutput(preview))}\n` : renderPlan(preview);
|
|
58
|
+
if (args.output === undefined)
|
|
59
|
+
io.out.write(rendered);
|
|
60
|
+
else
|
|
61
|
+
await deps.writeFile(resolve(deps.cwd, args.output), rendered);
|
|
62
|
+
return EXIT.ok;
|
|
63
|
+
}
|
|
64
|
+
const runtime = registry.createRuntime(config.runtime, { models: config.models, env: deps.env });
|
|
65
|
+
const progress = new ProgressPrinter(io.err, { heartbeatMs: deps.heartbeatMs, now: deps.now });
|
|
66
|
+
const interrupt = new AbortController();
|
|
67
|
+
const stopListening = deps.onInterrupt?.(() => {
|
|
68
|
+
io.err.write("[ocra] Interrupted: stopping and writing a partial report (Ctrl-C again quits now)\n");
|
|
69
|
+
interrupt.abort();
|
|
70
|
+
});
|
|
71
|
+
let report;
|
|
72
|
+
try {
|
|
73
|
+
report = await runReview({
|
|
74
|
+
signal: interrupt.signal,
|
|
75
|
+
...runOptions(config),
|
|
76
|
+
reviewerOverrides: overrides,
|
|
77
|
+
...(args.maxCostUsd !== undefined ? { maxCostUsd: args.maxCostUsd } : {}),
|
|
78
|
+
...(args.ultra ? { ultra: true } : {}),
|
|
79
|
+
...(args.full ? { fullReview: true } : {}),
|
|
80
|
+
...(target.readTrusted ? { readTrusted: target.readTrusted } : {}),
|
|
81
|
+
vcs,
|
|
82
|
+
runtime,
|
|
83
|
+
reviewers: registry.reviewers,
|
|
84
|
+
rules: [...registry.rules, ...config.rules],
|
|
85
|
+
onEvent: (event) => {
|
|
86
|
+
registry.emit(event);
|
|
87
|
+
progress.onEvent(event);
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
stopListening?.();
|
|
93
|
+
progress.stop();
|
|
94
|
+
await runtime.dispose?.();
|
|
95
|
+
}
|
|
96
|
+
const sessionDir = relative(deps.cwd, join(session.dir, session.id)) || ".";
|
|
97
|
+
const rendered = args.format === "json" ? renderJson(report) : renderText(report, sessionDir);
|
|
98
|
+
if (args.output === undefined) {
|
|
99
|
+
io.out.write(rendered);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
await deps.writeFile(resolve(deps.cwd, args.output), rendered);
|
|
103
|
+
io.err.write(`[ocra] Wrote ${args.output}\n`);
|
|
104
|
+
}
|
|
105
|
+
if (interrupt.signal.aborted)
|
|
106
|
+
return EXIT.interrupted;
|
|
107
|
+
if (target.publish) {
|
|
108
|
+
// Stopping halfway could post the inline comments without the summary
|
|
109
|
+
// that remembers them; a first Ctrl-C only warns.
|
|
110
|
+
const stopHolding = deps.onInterrupt?.(() => {
|
|
111
|
+
io.err.write("[ocra] Publishing; Ctrl-C again quits now and may leave the pull request half updated\n");
|
|
112
|
+
});
|
|
113
|
+
try {
|
|
114
|
+
const published = await vcs.publish(report);
|
|
115
|
+
for (const warning of published.warnings)
|
|
116
|
+
warn(warning);
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
stopHolding?.();
|
|
120
|
+
}
|
|
121
|
+
io.err.write("[ocra] Published the review to the pull request\n");
|
|
122
|
+
}
|
|
123
|
+
return exitCode(report, io.err);
|
|
124
|
+
}
|
|
125
|
+
function runOptions(config) {
|
|
126
|
+
const options = {
|
|
127
|
+
selection: { ...defaultSelectionPolicy, include: config.include, exclude: config.exclude },
|
|
128
|
+
};
|
|
129
|
+
if (config.concurrency !== undefined)
|
|
130
|
+
options.concurrency = config.concurrency;
|
|
131
|
+
if (config.taskTimeoutMinutes !== undefined)
|
|
132
|
+
options.taskTimeoutMs = config.taskTimeoutMinutes * 60_000;
|
|
133
|
+
if (config.verify !== undefined)
|
|
134
|
+
options.verify = config.verify;
|
|
135
|
+
if (config.judge !== undefined)
|
|
136
|
+
options.judge = config.judge;
|
|
137
|
+
if (config.maxCostUsd !== undefined)
|
|
138
|
+
options.maxCostUsd = config.maxCostUsd;
|
|
139
|
+
if (config.maxTasks !== undefined)
|
|
140
|
+
options.maxTasks = config.maxTasks;
|
|
141
|
+
if (config.runTimeoutMinutes !== undefined)
|
|
142
|
+
options.runTimeoutMs = config.runTimeoutMinutes * 60_000;
|
|
143
|
+
return options;
|
|
144
|
+
}
|
|
145
|
+
// --reviewers narrows the run to the named reviewers on top of the config.
|
|
146
|
+
function reviewerOverrides(config, args, registered) {
|
|
147
|
+
if (args.reviewers === undefined)
|
|
148
|
+
return config.reviewers;
|
|
149
|
+
const unknown = args.reviewers.filter((id) => !registered.includes(id));
|
|
150
|
+
if (unknown.length > 0) {
|
|
151
|
+
throw new ConfigError(`Unknown reviewer(s): ${unknown.join(", ")} (available: ${registered.join(", ")})`);
|
|
152
|
+
}
|
|
153
|
+
const overrides = { ...config.reviewers };
|
|
154
|
+
for (const id of registered) {
|
|
155
|
+
if (!args.reviewers.includes(id))
|
|
156
|
+
overrides[id] = { ...overrides[id], enabled: false };
|
|
157
|
+
}
|
|
158
|
+
return overrides;
|
|
159
|
+
}
|
|
160
|
+
function exitCode(report, err) {
|
|
161
|
+
if (report.tasks.length > 0 && report.tasks.every((t) => t.status !== "completed")) {
|
|
162
|
+
err.write("[ocra] No review task completed; see the errors above.\n");
|
|
163
|
+
return EXIT.error;
|
|
164
|
+
}
|
|
165
|
+
// Incomplete comes first, even over a blocking verdict: the action lets
|
|
166
|
+
// exit 1 pass unless fail-on-concerns is set, and a review that missed
|
|
167
|
+
// files or could not check a critical finding must never pass.
|
|
168
|
+
const { notReviewed } = coverageGaps(report.coverage);
|
|
169
|
+
if (notReviewed > 0) {
|
|
170
|
+
err.write(`[ocra] ${notReviewed} selected file(s) were not reviewed; the review is incomplete.\n`);
|
|
171
|
+
return EXIT.incomplete;
|
|
172
|
+
}
|
|
173
|
+
if (report.unverifiedCriticals > 0) {
|
|
174
|
+
err.write(`[ocra] ${report.unverifiedCriticals} critical finding(s) could not be verified; the review is incomplete.\n`);
|
|
175
|
+
return EXIT.incomplete;
|
|
176
|
+
}
|
|
177
|
+
if (report.verdict !== "significant_concerns")
|
|
178
|
+
return EXIT.ok;
|
|
179
|
+
const override = report.changeRequest.override;
|
|
180
|
+
if (!override)
|
|
181
|
+
return EXIT.blocking;
|
|
182
|
+
err.write(`[ocra] The blocking verdict was overridden by ${forTerminal(override.by)}: ${forTerminal(override.reason)}\n`);
|
|
183
|
+
return EXIT.ok;
|
|
184
|
+
}
|
|
185
|
+
//# sourceMappingURL=command.js.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type ModelChains, type RepoRule } from "@open-cr-agent/core";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
export declare const CONFIG_PATH = ".ocra/config.json";
|
|
4
|
+
declare const configSchema: z.ZodObject<{
|
|
5
|
+
models: z.ZodDefault<z.ZodObject<{
|
|
6
|
+
top: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<string[], string | string[]>>>;
|
|
7
|
+
standard: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<string[], string | string[]>>>;
|
|
8
|
+
light: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<string[], string | string[]>>>;
|
|
9
|
+
}, z.core.$strict>>;
|
|
10
|
+
concurrency: z.ZodOptional<z.ZodNumber>;
|
|
11
|
+
taskTimeoutMinutes: z.ZodOptional<z.ZodNumber>;
|
|
12
|
+
runTimeoutMinutes: z.ZodOptional<z.ZodNumber>;
|
|
13
|
+
verify: z.ZodOptional<z.ZodBoolean>;
|
|
14
|
+
judge: z.ZodOptional<z.ZodBoolean>;
|
|
15
|
+
maxCostUsd: z.ZodOptional<z.ZodNumber>;
|
|
16
|
+
maxTasks: z.ZodOptional<z.ZodNumber>;
|
|
17
|
+
github: z.ZodDefault<z.ZodObject<{
|
|
18
|
+
requestChanges: z.ZodOptional<z.ZodBoolean>;
|
|
19
|
+
botLogin: z.ZodOptional<z.ZodString>;
|
|
20
|
+
}, z.core.$strict>>;
|
|
21
|
+
include: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
22
|
+
exclude: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
23
|
+
runtime: z.ZodDefault<z.ZodString>;
|
|
24
|
+
plugins: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
25
|
+
reviewers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
26
|
+
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
27
|
+
minTier: z.ZodOptional<z.ZodEnum<{
|
|
28
|
+
full: "full";
|
|
29
|
+
lite: "lite";
|
|
30
|
+
trivial: "trivial";
|
|
31
|
+
}>>;
|
|
32
|
+
}, z.core.$strict>>>;
|
|
33
|
+
pluginSettings: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
34
|
+
extends: z.ZodOptional<z.ZodString>;
|
|
35
|
+
}, z.core.$strict>;
|
|
36
|
+
export type CliConfig = Omit<z.infer<typeof configSchema>, "models"> & {
|
|
37
|
+
models: ModelChains;
|
|
38
|
+
rules: RepoRule[];
|
|
39
|
+
};
|
|
40
|
+
export interface LoadOptions {
|
|
41
|
+
repository: boolean;
|
|
42
|
+
read?: (path: string) => Promise<string | undefined>;
|
|
43
|
+
fetch?: typeof fetch;
|
|
44
|
+
warn?: (message: string) => void;
|
|
45
|
+
}
|
|
46
|
+
export declare class ConfigError extends Error {
|
|
47
|
+
}
|
|
48
|
+
export declare function loadConfig(root: string, env: Readonly<Record<string, string | undefined>>, options?: LoadOptions): Promise<CliConfig>;
|
|
49
|
+
export {};
|
|
50
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { RISK_TIERS, } from "@open-cr-agent/core";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { fetchRemoteConfig, mergeConfig } from "./remote-config.js";
|
|
6
|
+
export const CONFIG_PATH = ".ocra/config.json";
|
|
7
|
+
const modelChain = z
|
|
8
|
+
.union([z.string().min(1), z.array(z.string().min(1)).min(1)])
|
|
9
|
+
.transform((value) => (typeof value === "string" ? [value] : value));
|
|
10
|
+
const configSchema = z
|
|
11
|
+
.object({
|
|
12
|
+
models: z
|
|
13
|
+
.object({ top: modelChain, standard: modelChain, light: modelChain })
|
|
14
|
+
.partial()
|
|
15
|
+
.strict()
|
|
16
|
+
.default({}),
|
|
17
|
+
concurrency: z.number().int().min(1).max(32).optional(),
|
|
18
|
+
taskTimeoutMinutes: z.number().positive().optional(),
|
|
19
|
+
runTimeoutMinutes: z.number().positive().optional(),
|
|
20
|
+
verify: z.boolean().optional(),
|
|
21
|
+
judge: z.boolean().optional(),
|
|
22
|
+
maxCostUsd: z.number().positive().optional(),
|
|
23
|
+
maxTasks: z.number().int().min(1).max(1_000).optional(),
|
|
24
|
+
github: z
|
|
25
|
+
.object({ requestChanges: z.boolean(), botLogin: z.string().min(1) })
|
|
26
|
+
.partial()
|
|
27
|
+
.strict()
|
|
28
|
+
.default({}),
|
|
29
|
+
include: z.array(z.string().min(1)).default([]),
|
|
30
|
+
exclude: z.array(z.string().min(1)).default([]),
|
|
31
|
+
runtime: z.string().min(1).default("opencode"),
|
|
32
|
+
plugins: z.array(z.string().min(1)).default([]),
|
|
33
|
+
reviewers: z
|
|
34
|
+
.record(z.string(), z
|
|
35
|
+
.object({
|
|
36
|
+
enabled: z.boolean(),
|
|
37
|
+
minTier: z.enum(RISK_TIERS),
|
|
38
|
+
})
|
|
39
|
+
.partial()
|
|
40
|
+
.strict())
|
|
41
|
+
.default({}),
|
|
42
|
+
pluginSettings: z.record(z.string(), z.unknown()).default({}),
|
|
43
|
+
extends: z.string().min(1).optional(),
|
|
44
|
+
})
|
|
45
|
+
.strict();
|
|
46
|
+
const MODEL_ENV = {
|
|
47
|
+
top: "OCRA_MODEL_TOP",
|
|
48
|
+
standard: "OCRA_MODEL_STANDARD",
|
|
49
|
+
light: "OCRA_MODEL_LIGHT",
|
|
50
|
+
};
|
|
51
|
+
export class ConfigError extends Error {
|
|
52
|
+
}
|
|
53
|
+
// Without the repository's file, only defaults and environment variables
|
|
54
|
+
// apply: that file can name plugins, and plugins run code.
|
|
55
|
+
export async function loadConfig(root, env, options = { repository: true }) {
|
|
56
|
+
const read = options.read ?? ((path) => readConfigFromDisk(root, path));
|
|
57
|
+
const { config: parsed, rules } = options.repository
|
|
58
|
+
? await readConfigFile(read, options)
|
|
59
|
+
: { config: configSchema.parse({}), rules: [] };
|
|
60
|
+
const models = {};
|
|
61
|
+
for (const [tier, chain] of Object.entries(parsed.models)) {
|
|
62
|
+
if (chain)
|
|
63
|
+
models[tier] = chain;
|
|
64
|
+
}
|
|
65
|
+
for (const [tier, name] of Object.entries(MODEL_ENV)) {
|
|
66
|
+
const chain = (env[name] ?? "")
|
|
67
|
+
.split(",")
|
|
68
|
+
.map((m) => m.trim())
|
|
69
|
+
.filter((m) => m !== "");
|
|
70
|
+
if (chain.length > 0)
|
|
71
|
+
models[tier] = chain;
|
|
72
|
+
}
|
|
73
|
+
return { ...parsed, models, rules };
|
|
74
|
+
}
|
|
75
|
+
// The user's own checkout, read like any local file (links followed): unlike
|
|
76
|
+
// the review's reads, which never follow links (vcs-local), this is trusted
|
|
77
|
+
// configuration, and --no-repo-config skips it for code that is not.
|
|
78
|
+
async function readConfigFromDisk(root, path) {
|
|
79
|
+
try {
|
|
80
|
+
return await readFile(join(root, path), "utf8");
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (error.code === "ENOENT")
|
|
84
|
+
return undefined;
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async function readConfigFile(read, options) {
|
|
89
|
+
const text = await read(CONFIG_PATH);
|
|
90
|
+
if (text === undefined)
|
|
91
|
+
return { config: configSchema.parse({}), rules: [] };
|
|
92
|
+
let data;
|
|
93
|
+
try {
|
|
94
|
+
data = JSON.parse(text);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
throw new ConfigError(`${CONFIG_PATH} is not valid JSON: ${error.message}`);
|
|
98
|
+
}
|
|
99
|
+
const local = configSchema.safeParse(data);
|
|
100
|
+
if (!local.success)
|
|
101
|
+
throw new ConfigError(`${CONFIG_PATH} is invalid: ${z.prettifyError(local.error)}`);
|
|
102
|
+
if (!local.data.extends)
|
|
103
|
+
return { config: local.data, rules: [] };
|
|
104
|
+
// A shared configuration that cannot be loaded costs its defaults, not the
|
|
105
|
+
// review: the repository's own settings still apply.
|
|
106
|
+
let remote;
|
|
107
|
+
try {
|
|
108
|
+
remote = await fetchRemoteConfig(local.data.extends, options.fetch);
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
// Its limits are lost with it; say so rather than run silently unbounded.
|
|
112
|
+
const limits = local.data.maxCostUsd === undefined
|
|
113
|
+
? "; none of its settings apply, including any spend or task limit"
|
|
114
|
+
: "; none of its settings apply";
|
|
115
|
+
options.warn?.(`could not load extends ${local.data.extends}: ${error.message}${limits}`);
|
|
116
|
+
return { config: local.data, rules: [] };
|
|
117
|
+
}
|
|
118
|
+
const merged = configSchema.safeParse(mergeConfig(remote, data));
|
|
119
|
+
if (!merged.success) {
|
|
120
|
+
options.warn?.(`ignoring extends ${local.data.extends}: its settings are invalid (${z.prettifyError(merged.error)})`);
|
|
121
|
+
return { config: local.data, rules: [] };
|
|
122
|
+
}
|
|
123
|
+
return { config: merged.data, rules: remote.rules ?? [] };
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { forTerminal } from "./terminal.js";
|
|
2
|
+
const number = new Intl.NumberFormat("en-US");
|
|
3
|
+
export function renderPlan(preview) {
|
|
4
|
+
const lines = [
|
|
5
|
+
`Plan: ${preview.changeRequest.title}`,
|
|
6
|
+
`Risk tier: ${preview.tier} · ${preview.selected.length} selected · ${preview.excluded.length} excluded`,
|
|
7
|
+
];
|
|
8
|
+
if (preview.excluded.length > 0) {
|
|
9
|
+
lines.push("", "Excluded:");
|
|
10
|
+
for (const e of preview.excluded)
|
|
11
|
+
lines.push(` ${e.path} (${e.reason.replaceAll("_", " ")})`);
|
|
12
|
+
}
|
|
13
|
+
lines.push("", `Bundles: ${preview.bundles.length}`);
|
|
14
|
+
if (preview.groupingSkipped) {
|
|
15
|
+
lines.push(" (shown per file: a real run lets the light model group related files first)");
|
|
16
|
+
}
|
|
17
|
+
preview.bundles.forEach((b, i) => {
|
|
18
|
+
lines.push(` ${i + 1}. ${b.label}${b.files.length > 1 || b.files[0] !== b.label ? `: ${b.files.join(", ")}` : ""}`);
|
|
19
|
+
});
|
|
20
|
+
lines.push("", `Review tasks: ${preview.tasks.length}`);
|
|
21
|
+
const width = Math.max(0, ...preview.tasks.map((t) => t.taskId.length));
|
|
22
|
+
for (const t of preview.tasks) {
|
|
23
|
+
lines.push(` ${t.taskId.padEnd(width)} ~${number.format(t.promptTokens)} prompt tokens${t.planPromptTokens === undefined ? "" : ` + plan ~${number.format(t.planPromptTokens)}`} ${t.bundle}`);
|
|
24
|
+
}
|
|
25
|
+
if (preview.skipped.length > 0) {
|
|
26
|
+
lines.push("", "Skipped by the review matrix:");
|
|
27
|
+
for (const s of preview.skipped) {
|
|
28
|
+
lines.push(` ${s.reviewer} on ${s.bundle} (${s.reason.replaceAll("_", " ")})`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
for (const warning of preview.warnings)
|
|
32
|
+
lines.push(`Warning: ${warning}`);
|
|
33
|
+
lines.push("", `First prompts: ~${number.format(preview.promptTokens)} input tokens in total${preview.planCalls > 0 ? `, ${preview.planCalls} plan call(s) included` : ""}. This is a floor: agents read files and take several turns, so a run usually uses several times as much, plus verification and judging. No model was called.`);
|
|
34
|
+
return forTerminal(`${lines.join("\n")}\n`);
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=plan-render.js.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { ConfigError } from "./config.js";
|
|
5
|
+
// Plugins listed in the repository config execute code, so they are resolved
|
|
6
|
+
// only from the repository's own dependencies or relative paths inside it.
|
|
7
|
+
export async function loadExternalPlugins(specifiers, root) {
|
|
8
|
+
const plugins = [];
|
|
9
|
+
for (const specifier of specifiers) {
|
|
10
|
+
const module = await import(pathToFileURL(resolvePlugin(specifier, root)).href);
|
|
11
|
+
const plugin = module.default ?? module.plugin;
|
|
12
|
+
if (!isPlugin(plugin)) {
|
|
13
|
+
throw new ConfigError(`Plugin "${specifier}" must export an ocra plugin as default or "plugin"`);
|
|
14
|
+
}
|
|
15
|
+
plugins.push(plugin);
|
|
16
|
+
}
|
|
17
|
+
return plugins;
|
|
18
|
+
}
|
|
19
|
+
function resolvePlugin(specifier, root) {
|
|
20
|
+
if (specifier.startsWith(".") || isAbsolute(specifier))
|
|
21
|
+
return resolve(root, specifier);
|
|
22
|
+
try {
|
|
23
|
+
return createRequire(join(root, "package.json")).resolve(specifier);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new ConfigError(`Cannot find plugin "${specifier}" from ${root}; install it as a dependency`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function isPlugin(value) {
|
|
30
|
+
return (typeof value === "object" && value !== null && typeof value.name === "string");
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=plugins.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ReviewEvent } from "@open-cr-agent/core";
|
|
2
|
+
export interface Output {
|
|
3
|
+
write(chunk: string): unknown;
|
|
4
|
+
}
|
|
5
|
+
export interface ProgressOptions {
|
|
6
|
+
heartbeatMs: number;
|
|
7
|
+
now: () => number;
|
|
8
|
+
}
|
|
9
|
+
export declare class ProgressPrinter {
|
|
10
|
+
private readonly err;
|
|
11
|
+
private readonly options;
|
|
12
|
+
private lastOutput;
|
|
13
|
+
private readonly timer;
|
|
14
|
+
constructor(err: Output, options: ProgressOptions);
|
|
15
|
+
onEvent(event: ReviewEvent): void;
|
|
16
|
+
stop(): void;
|
|
17
|
+
private heartbeat;
|
|
18
|
+
private print;
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=progress.d.ts.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { forTerminal } from "./terminal.js";
|
|
2
|
+
export class ProgressPrinter {
|
|
3
|
+
err;
|
|
4
|
+
options;
|
|
5
|
+
lastOutput;
|
|
6
|
+
timer;
|
|
7
|
+
constructor(err, options) {
|
|
8
|
+
this.err = err;
|
|
9
|
+
this.options = options;
|
|
10
|
+
this.lastOutput = options.now();
|
|
11
|
+
this.timer = setInterval(() => this.heartbeat(), options.heartbeatMs);
|
|
12
|
+
this.timer.unref?.();
|
|
13
|
+
}
|
|
14
|
+
onEvent(event) {
|
|
15
|
+
const line = describe(event);
|
|
16
|
+
if (line !== undefined)
|
|
17
|
+
this.print(line);
|
|
18
|
+
}
|
|
19
|
+
stop() {
|
|
20
|
+
clearInterval(this.timer);
|
|
21
|
+
}
|
|
22
|
+
heartbeat() {
|
|
23
|
+
const silentMs = this.options.now() - this.lastOutput;
|
|
24
|
+
if (silentMs >= this.options.heartbeatMs) {
|
|
25
|
+
this.print(`Model is thinking... (${Math.round(silentMs / 1000)}s since last update)`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
print(line) {
|
|
29
|
+
this.lastOutput = this.options.now();
|
|
30
|
+
this.err.write(`[ocra] ${forTerminal(line)}\n`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function describe(event) {
|
|
34
|
+
switch (event.type) {
|
|
35
|
+
case "run_started":
|
|
36
|
+
return `Reviewing: ${event.changeRequest.title}`;
|
|
37
|
+
case "files_selected":
|
|
38
|
+
return `${event.selected} file(s) selected, ${event.excluded} excluded · risk tier: ${event.tier}`;
|
|
39
|
+
case "files_bundled":
|
|
40
|
+
return [`${event.bundles} bundle(s) (${event.strategy})`, ...event.warnings].join("\n[ocra] ");
|
|
41
|
+
case "matrix_planned": {
|
|
42
|
+
const skipped = event.skipped.length;
|
|
43
|
+
return `${event.tasks} review task(s)${skipped > 0 ? `, ${skipped} reviewer/bundle pair(s) skipped` : ""}`;
|
|
44
|
+
}
|
|
45
|
+
case "verification_finished":
|
|
46
|
+
return `Verified ${event.checked} finding(s)${event.refuted.length > 0 ? `, dropped ${event.refuted.length} that the code disproves` : ""}`;
|
|
47
|
+
case "judge_finished": {
|
|
48
|
+
const j = event.judgement;
|
|
49
|
+
const changes = j
|
|
50
|
+
? ` (merged ${j.merged.length}, dropped ${j.dropped.length}, recalibrated ${j.recalibrated.length})`
|
|
51
|
+
: "";
|
|
52
|
+
return `Verdict: ${event.verdict.replaceAll("_", " ")}${changes}`;
|
|
53
|
+
}
|
|
54
|
+
case "task_progress":
|
|
55
|
+
return `${event.taskId} ${event.message}`;
|
|
56
|
+
case "task_started":
|
|
57
|
+
return `${event.taskId} started: ${event.bundle} (${event.files.length} file(s))`;
|
|
58
|
+
case "task_finished": {
|
|
59
|
+
const { outcome } = event;
|
|
60
|
+
const seconds = (outcome.durationMs / 1000).toFixed(1);
|
|
61
|
+
return outcome.status === "completed"
|
|
62
|
+
? `${outcome.taskId} completed in ${seconds}s · ${outcome.findings} finding(s)`
|
|
63
|
+
: `${outcome.taskId} ${outcome.status.replace("_", " ")} after ${seconds}s: ${outcome.error ?? "unknown error"}`;
|
|
64
|
+
}
|
|
65
|
+
default:
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=progress.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const remoteConfigSchema: z.ZodObject<{
|
|
3
|
+
models: z.ZodOptional<z.ZodRecord<z.ZodEnum<{
|
|
4
|
+
light: "light";
|
|
5
|
+
standard: "standard";
|
|
6
|
+
top: "top";
|
|
7
|
+
}>, z.ZodUnknown>>;
|
|
8
|
+
concurrency: z.ZodOptional<z.ZodUnknown>;
|
|
9
|
+
taskTimeoutMinutes: z.ZodOptional<z.ZodUnknown>;
|
|
10
|
+
runTimeoutMinutes: z.ZodOptional<z.ZodUnknown>;
|
|
11
|
+
verify: z.ZodOptional<z.ZodUnknown>;
|
|
12
|
+
judge: z.ZodOptional<z.ZodUnknown>;
|
|
13
|
+
maxCostUsd: z.ZodOptional<z.ZodUnknown>;
|
|
14
|
+
maxTasks: z.ZodOptional<z.ZodUnknown>;
|
|
15
|
+
include: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
16
|
+
exclude: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
17
|
+
reviewers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
18
|
+
github: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
19
|
+
rules: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
20
|
+
path: z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>;
|
|
21
|
+
rule: z.ZodString;
|
|
22
|
+
}, z.core.$strip>>>;
|
|
23
|
+
}, z.core.$strict>;
|
|
24
|
+
export type RemoteConfig = z.infer<typeof remoteConfigSchema>;
|
|
25
|
+
export declare function fetchRemoteConfig(spec: string, fetchImpl?: typeof fetch): Promise<RemoteConfig>;
|
|
26
|
+
export declare function mergeConfig(remote: RemoteConfig, local: Record<string, unknown>): Record<string, unknown>;
|
|
27
|
+
//# sourceMappingURL=remote-config.d.ts.map
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { repoRuleSchema } from "@open-cr-agent/core";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
const MAX_BYTES = 256 * 1024;
|
|
5
|
+
const TIMEOUT_MS = 10_000;
|
|
6
|
+
// What a shared configuration may set: settings and rules, never plugins or
|
|
7
|
+
// a runtime, because it is fetched from outside the repository.
|
|
8
|
+
export const remoteConfigSchema = z
|
|
9
|
+
.object({
|
|
10
|
+
models: z.record(z.enum(["top", "standard", "light"]), z.unknown()).optional(),
|
|
11
|
+
concurrency: z.unknown().optional(),
|
|
12
|
+
taskTimeoutMinutes: z.unknown().optional(),
|
|
13
|
+
runTimeoutMinutes: z.unknown().optional(),
|
|
14
|
+
verify: z.unknown().optional(),
|
|
15
|
+
judge: z.unknown().optional(),
|
|
16
|
+
maxCostUsd: z.unknown().optional(),
|
|
17
|
+
maxTasks: z.unknown().optional(),
|
|
18
|
+
include: z.array(z.string()).optional(),
|
|
19
|
+
exclude: z.array(z.string()).optional(),
|
|
20
|
+
reviewers: z.record(z.string(), z.unknown()).optional(),
|
|
21
|
+
github: z.record(z.string(), z.unknown()).optional(),
|
|
22
|
+
rules: z.array(repoRuleSchema).max(500).optional(),
|
|
23
|
+
})
|
|
24
|
+
.strict();
|
|
25
|
+
// "https://example.com/ocra.json#sha256=<hex>": the fragment pins the content.
|
|
26
|
+
export async function fetchRemoteConfig(spec, fetchImpl = fetch) {
|
|
27
|
+
const [address = "", fragment] = spec.split("#", 2);
|
|
28
|
+
const url = new URL(address);
|
|
29
|
+
if (url.protocol !== "https:")
|
|
30
|
+
throw new Error(`extends must be an https URL, got ${url.protocol}`);
|
|
31
|
+
const pinned = fragment?.startsWith("sha256=")
|
|
32
|
+
? fragment.slice("sha256=".length).toLowerCase()
|
|
33
|
+
: undefined;
|
|
34
|
+
const response = await fetchImpl(url, {
|
|
35
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
36
|
+
headers: { Accept: "application/json" },
|
|
37
|
+
redirect: "error",
|
|
38
|
+
});
|
|
39
|
+
if (!response.ok)
|
|
40
|
+
throw new Error(`${url.href} answered ${response.status}`);
|
|
41
|
+
const body = await readLimited(response, MAX_BYTES, url.href);
|
|
42
|
+
if (pinned !== undefined) {
|
|
43
|
+
const actual = createHash("sha256").update(body).digest("hex");
|
|
44
|
+
if (actual !== pinned)
|
|
45
|
+
throw new Error(`${url.href} does not match its pinned sha256`);
|
|
46
|
+
}
|
|
47
|
+
const parsed = remoteConfigSchema.safeParse(JSON.parse(body));
|
|
48
|
+
if (!parsed.success)
|
|
49
|
+
throw new Error(`${url.href} is invalid: ${z.prettifyError(parsed.error)}`);
|
|
50
|
+
return parsed.data;
|
|
51
|
+
}
|
|
52
|
+
// Stops reading at the limit instead of buffering whatever the server sends.
|
|
53
|
+
async function readLimited(response, limit, name) {
|
|
54
|
+
const reader = response.body?.getReader();
|
|
55
|
+
if (!reader)
|
|
56
|
+
return "";
|
|
57
|
+
const chunks = [];
|
|
58
|
+
let size = 0;
|
|
59
|
+
for (;;) {
|
|
60
|
+
const { done, value } = await reader.read();
|
|
61
|
+
if (done)
|
|
62
|
+
break;
|
|
63
|
+
size += value.byteLength;
|
|
64
|
+
if (size > limit) {
|
|
65
|
+
await reader.cancel();
|
|
66
|
+
throw new Error(`${name} is larger than 256 KB`);
|
|
67
|
+
}
|
|
68
|
+
chunks.push(value);
|
|
69
|
+
}
|
|
70
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
71
|
+
}
|
|
72
|
+
// The repository's own values win; lists and maps are combined.
|
|
73
|
+
export function mergeConfig(remote, local) {
|
|
74
|
+
const { rules: _rules, ...settings } = remote;
|
|
75
|
+
const merged = { ...settings, ...local };
|
|
76
|
+
for (const key of ["models", "reviewers", "github"]) {
|
|
77
|
+
const a = settings[key];
|
|
78
|
+
const b = local[key];
|
|
79
|
+
if (a !== undefined || b !== undefined)
|
|
80
|
+
merged[key] = { ...(a ?? {}), ...(b ?? {}) };
|
|
81
|
+
}
|
|
82
|
+
for (const key of ["include", "exclude"]) {
|
|
83
|
+
const a = settings[key] ?? [];
|
|
84
|
+
const b = local[key] ?? [];
|
|
85
|
+
if (a.length + b.length > 0)
|
|
86
|
+
merged[key] = [...a, ...b];
|
|
87
|
+
}
|
|
88
|
+
return merged;
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=remote-config.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type ReviewReport, serializeOutput } from "@open-cr-agent/core";
|
|
2
|
+
export declare function renderJson(report: ReviewReport): string;
|
|
3
|
+
export declare const safeJson: typeof serializeOutput;
|
|
4
|
+
export declare function renderText(report: ReviewReport, sessionDir?: string): string;
|
|
5
|
+
//# sourceMappingURL=render.d.ts.map
|