@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.
@@ -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,3 @@
1
+ import type { ReviewPreview } from "@open-cr-agent/core";
2
+ export declare function renderPlan(preview: ReviewPreview): string;
3
+ //# sourceMappingURL=plan-render.d.ts.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,3 @@
1
+ import type { OcraPlugin } from "@open-cr-agent/core";
2
+ export declare function loadExternalPlugins(specifiers: readonly string[], root: string): Promise<OcraPlugin[]>;
3
+ //# sourceMappingURL=plugins.d.ts.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