@open-cr-agent/cli 0.2.0 → 0.4.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.
@@ -1,5 +1,10 @@
1
1
  import { parseArgs } from "node:util";
2
- export class UsageError extends Error {
2
+ import { OcraError } from "@open-cr-agent/core";
3
+ export class UsageError extends OcraError {
4
+ constructor(message) {
5
+ super("INPUT_USAGE", message);
6
+ this.name = "UsageError";
7
+ }
3
8
  }
4
9
  export const REVIEW_USAGE = `Usage: ocra review [options]
5
10
 
@@ -20,10 +25,18 @@ Options:
20
25
  --output <file> Write the result to a file instead of stdout
21
26
  --reviewers <ids> Run only these reviewers (comma-separated)
22
27
  --max-cost-usd <n> Spend limit: reviews use 80%, verification and judging the rest
28
+ --import-sarif <file> Add the results of a SARIF 2.1.0 log (Semgrep, CodeQL, …)
29
+ that fall on the change; repeatable; not with --plan
23
30
  --plan Show files, bundles, review tasks and prompt sizes; call no model
24
31
  --ultra Favor recall: all reviewers at every tier, two samples each (about 2x cost)
32
+ --temperature <n> Sampling temperature, 0 to 2 (default: the provider's, or sampling in config)
33
+ --seed <n> Sampling seed, where the runtime and provider support one
34
+ --config <file> Read this configuration file instead of the repository's
35
+ .ocra/config.json; it applies with --no-repo-config too
25
36
  --no-repo-config Ignore .ocra/config.json and its plugins (for untrusted
26
- code); models come from OCRA_MODEL_* variables
37
+ code); models come from OCRA_MODEL_* variables or --config
38
+ --no-upload When signed in to ocra Cloud, do not send this review's
39
+ counts (verdict, numbers of findings, files, tokens)
27
40
  -h, --help Show help
28
41
  `;
29
42
  export function parseReviewArgs(argv) {
@@ -59,6 +72,8 @@ export function parseReviewArgs(argv) {
59
72
  args.output = values.output;
60
73
  if (values["no-repo-config"])
61
74
  args.ignoreRepoConfig = true;
75
+ if (values["no-upload"])
76
+ args.noUpload = true;
62
77
  if (values.ultra)
63
78
  args.ultra = true;
64
79
  if (values.full) {
@@ -79,6 +94,30 @@ export function parseReviewArgs(argv) {
79
94
  throw new UsageError(`--max-cost-usd must be a positive number, got ${values["max-cost-usd"]}`);
80
95
  args.maxCostUsd = max;
81
96
  }
97
+ if (values.temperature !== undefined) {
98
+ const temperature = Number(values.temperature);
99
+ if (values.temperature.trim() === "" || !(temperature >= 0 && temperature <= 2)) {
100
+ throw new UsageError(`--temperature must be a number from 0 to 2, got ${values.temperature}`);
101
+ }
102
+ args.temperature = temperature;
103
+ }
104
+ if (values.seed !== undefined) {
105
+ const seed = Number(values.seed);
106
+ if (!/^\d+$/.test(values.seed) || seed > 2 ** 31 - 1) {
107
+ throw new UsageError(`--seed must be a whole number from 0 to 2147483647, got ${values.seed}`);
108
+ }
109
+ args.seed = seed;
110
+ }
111
+ if (values.config !== undefined) {
112
+ if (values.config === "")
113
+ throw new UsageError("--config needs a file");
114
+ args.configFile = values.config;
115
+ }
116
+ if (values["import-sarif"] !== undefined && values["import-sarif"].length > 0) {
117
+ if (values.plan)
118
+ throw new UsageError("--import-sarif is not used by --plan");
119
+ args.importSarif = values["import-sarif"];
120
+ }
82
121
  if (values.reviewers !== undefined) {
83
122
  const ids = values.reviewers
84
123
  .split(",")
@@ -102,9 +141,14 @@ function parse(argv) {
102
141
  format: { type: "string" },
103
142
  output: { type: "string" },
104
143
  "no-repo-config": { type: "boolean" },
144
+ "no-upload": { type: "boolean" },
145
+ config: { type: "string" },
105
146
  reviewers: { type: "string" },
106
147
  "max-cost-usd": { type: "string" },
148
+ "import-sarif": { type: "string", multiple: true },
107
149
  ultra: { type: "boolean" },
150
+ temperature: { type: "string" },
151
+ seed: { type: "string" },
108
152
  full: { type: "boolean" },
109
153
  plan: { type: "boolean" },
110
154
  pr: { type: "string" },
@@ -0,0 +1,12 @@
1
+ import type { CustomProvider, ModelChains } from "@open-cr-agent/core";
2
+ import { type CloudDeps } from "../cloud.js";
3
+ export declare const CLOUD_PREFIX = "ocra-";
4
+ /** Where the access token travels to the runtime; never a user's variable (OCRA_ is refused in config). */
5
+ export declare const CLOUD_TOKEN_ENV = "OCRA_CLOUD_ACCESS_TOKEN";
6
+ type Env = Readonly<Record<string, string | undefined>>;
7
+ export declare function withCloudProviders(models: ModelChains, providers: Readonly<Record<string, CustomProvider>>, env: Env, deps: CloudDeps | undefined, warn: (message: string) => void): Promise<{
8
+ providers: Record<string, CustomProvider>;
9
+ env: Env;
10
+ }>;
11
+ export {};
12
+ //# sourceMappingURL=cloud-providers.d.ts.map
@@ -0,0 +1,59 @@
1
+ import { OcraError } from "@open-cr-agent/core";
2
+ import { cloudSession } from "../cloud.js";
3
+ // Models named ocra-<provider>/<model> go through the ocra Cloud gateway with
4
+ // the key stored there for <provider> (ADR-0024). Each such provider becomes
5
+ // an OpenAI-compatible endpoint declared for this run, authorized by the
6
+ // signed-in session's access token. Without such a model nothing here reads
7
+ // the session or touches the network.
8
+ export const CLOUD_PREFIX = "ocra-";
9
+ /** Where the access token travels to the runtime; never a user's variable (OCRA_ is refused in config). */
10
+ export const CLOUD_TOKEN_ENV = "OCRA_CLOUD_ACCESS_TOKEN";
11
+ // A review may run for half an hour; start it with a token that outlives it.
12
+ const MIN_TOKEN_MS = 35 * 60_000;
13
+ export async function withCloudProviders(models, providers, env, deps, warn) {
14
+ const wanted = new Map();
15
+ for (const model of Object.values(models).flat()) {
16
+ const slash = model.indexOf("/");
17
+ const id = model.slice(0, slash);
18
+ if (slash < 0 || !id.startsWith(CLOUD_PREFIX) || providers[id])
19
+ continue;
20
+ const set = wanted.get(id) ?? new Set();
21
+ set.add(model.slice(slash + 1));
22
+ wanted.set(id, set);
23
+ }
24
+ if (wanted.size === 0)
25
+ return { providers: { ...providers }, env };
26
+ const names = [...wanted.keys()].join(", ");
27
+ if (env.OCRA_CLOUD === "off") {
28
+ throw new OcraError("CONFIG_INVALID", `Models name ocra Cloud (${names}) but OCRA_CLOUD=off`);
29
+ }
30
+ const session = deps && (await cloudSession(deps, MIN_TOKEN_MS));
31
+ if (!session) {
32
+ throw new OcraError("CONFIG_CREDENTIALS_MISSING", `Models name ocra Cloud (${names}): sign in with ocra login`);
33
+ }
34
+ const res = await deps.fetch(`${session.server}/api/providers`, {
35
+ signal: AbortSignal.timeout(30_000),
36
+ });
37
+ if (!res.ok) {
38
+ throw new OcraError("RUNTIME_START_FAILED", `ocra Cloud answered ${res.status} for its providers`);
39
+ }
40
+ const listed = (await res.json()).providers ?? [];
41
+ const declared = { ...providers };
42
+ for (const [id, modelIds] of wanted) {
43
+ const name = id.slice(CLOUD_PREFIX.length);
44
+ const chat = listed
45
+ .find((p) => p.name === name)
46
+ ?.paths.find((path) => path.endsWith("/chat/completions"));
47
+ if (!chat) {
48
+ throw new OcraError("CONFIG_INVALID", `ocra Cloud has no OpenAI-compatible chat endpoint for "${name}" (named by ${id})`);
49
+ }
50
+ declared[id] = {
51
+ baseUrl: `${session.server}/api/gateway/${name}${chat.slice(0, -"/chat/completions".length)}`,
52
+ apiKeyEnv: CLOUD_TOKEN_ENV,
53
+ models: Object.fromEntries([...modelIds].map((m) => [m, { input: 0, output: 0 }])),
54
+ };
55
+ }
56
+ warn(`${names}: models through ocra Cloud are not priced, so reported cost and --max-cost-usd do not count them`);
57
+ return { providers: declared, env: { ...env, [CLOUD_TOKEN_ENV]: session.access_token } };
58
+ }
59
+ //# sourceMappingURL=cloud-providers.js.map
@@ -0,0 +1,40 @@
1
+ import { type ModelChains, type ReviewReport } from "@open-cr-agent/core";
2
+ import { type CloudDeps } from "../cloud.js";
3
+ export type ReviewSource = "local" | "github" | "gitlab";
4
+ export type ReviewUpload = {
5
+ runId: string;
6
+ repoHash: string;
7
+ source: ReviewSource;
8
+ tier: string;
9
+ verdict: string;
10
+ complete: boolean;
11
+ findings: {
12
+ critical: number;
13
+ warning: number;
14
+ suggestion: number;
15
+ };
16
+ files: {
17
+ reviewed: number;
18
+ notReviewed: number;
19
+ };
20
+ tasks: {
21
+ completed: number;
22
+ failed: number;
23
+ };
24
+ usage: {
25
+ inputTokens: number;
26
+ outputTokens: number;
27
+ costUsd: number;
28
+ };
29
+ durationMs: number;
30
+ ocraVersion: string;
31
+ };
32
+ export declare function uploadOf(report: ReviewReport, source: ReviewSource, repoHash: string, durationMs: number): ReviewUpload;
33
+ export declare function repoHash(root: string, credentialsPath: string): Promise<string>;
34
+ /** Whether this run talks to ocra Cloud at all: signed in, not turned off. */
35
+ export declare function cloudEnabled(deps: CloudDeps): Promise<boolean>;
36
+ /** Sends the counts; a failure is a warning, never a failed review. */
37
+ export declare function uploadReview(upload: ReviewUpload, deps: CloudDeps, warn: (message: string) => void): Promise<boolean>;
38
+ /** The default models chosen on the web, for a review that configures none. */
39
+ export declare function defaultModels(deps: CloudDeps): Promise<ModelChains>;
40
+ //# sourceMappingURL=cloud-upload.d.ts.map
@@ -0,0 +1,135 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { coverageGaps } from "@open-cr-agent/core";
7
+ import { cloudSession, readCredentials } from "../cloud.js";
8
+ import { VERSION } from "../version.js";
9
+ // After a review, a signed-in CLI sends ocra Cloud its counts (ADR-0024):
10
+ // the verdict, how many findings of each severity, files and tasks, tokens
11
+ // and time. Never a path, a title, a finding's text or code. The repository
12
+ // is a hash salted with a value that stays on this machine, so the server
13
+ // can group reviews of one repository without learning which it is.
14
+ const run = promisify(execFile);
15
+ export function uploadOf(report, source, repoHash, durationMs) {
16
+ const severity = (s) => report.findings.filter((f) => f.severity === s).length;
17
+ const gaps = coverageGaps(report);
18
+ return {
19
+ runId: report.runId,
20
+ repoHash,
21
+ source,
22
+ tier: report.tier,
23
+ verdict: report.verdict,
24
+ complete: gaps.notReviewed === 0,
25
+ findings: {
26
+ critical: severity("critical"),
27
+ warning: severity("warning"),
28
+ suggestion: severity("suggestion"),
29
+ },
30
+ files: {
31
+ reviewed: report.coverage.filter((c) => c.status === "reviewed").length,
32
+ notReviewed: gaps.notReviewed,
33
+ },
34
+ tasks: {
35
+ completed: report.tasks.filter((t) => t.status === "completed").length,
36
+ failed: report.tasks.filter((t) => t.status === "failed").length,
37
+ },
38
+ usage: {
39
+ inputTokens: report.usage.inputTokens,
40
+ outputTokens: report.usage.outputTokens,
41
+ costUsd: report.usage.costUsd ?? 0,
42
+ },
43
+ durationMs: Math.round(durationMs),
44
+ ocraVersion: VERSION,
45
+ };
46
+ }
47
+ /** The repository's origin without credentials, or its root path when it has none. */
48
+ async function repositoryId(root) {
49
+ try {
50
+ const { stdout } = await run("git", ["-C", root, "remote", "get-url", "origin"], {
51
+ timeout: 10_000,
52
+ });
53
+ const url = stdout.trim();
54
+ return url
55
+ .replace(/^[a-z+]+:\/\/[^@/]*@/i, (m) => m.slice(0, m.indexOf("//") + 2))
56
+ .replace(/\.git$/, "")
57
+ .toLowerCase();
58
+ }
59
+ catch {
60
+ return root;
61
+ }
62
+ }
63
+ /** A random salt kept beside the credentials, made on first use. */
64
+ async function salt(credentialsPath) {
65
+ const path = join(dirname(credentialsPath), "upload-salt");
66
+ try {
67
+ const existing = (await readFile(path, "utf8")).trim();
68
+ if (/^[0-9a-f]{64}$/.test(existing))
69
+ return existing;
70
+ }
71
+ catch { }
72
+ const fresh = randomBytes(32).toString("hex");
73
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
74
+ await writeFile(path, `${fresh}\n`, { mode: 0o600 });
75
+ return fresh;
76
+ }
77
+ export async function repoHash(root, credentialsPath) {
78
+ return createHash("sha256")
79
+ .update(`${await salt(credentialsPath)}:${await repositoryId(root)}`)
80
+ .digest("hex");
81
+ }
82
+ /** Whether this run talks to ocra Cloud at all: signed in, not turned off. */
83
+ export async function cloudEnabled(deps) {
84
+ return (deps.env.OCRA_CLOUD !== "off" && (await readCredentials(deps.credentialsPath)) !== undefined);
85
+ }
86
+ /** Sends the counts; a failure is a warning, never a failed review. */
87
+ export async function uploadReview(upload, deps, warn) {
88
+ try {
89
+ const session = await cloudSession(deps);
90
+ if (!session)
91
+ return false;
92
+ const res = await deps.fetch(`${session.server}/api/reviews`, {
93
+ method: "POST",
94
+ headers: {
95
+ "content-type": "application/json",
96
+ authorization: `Bearer ${session.access_token}`,
97
+ "user-agent": `ocra/${VERSION}`,
98
+ },
99
+ body: JSON.stringify(upload),
100
+ signal: AbortSignal.timeout(15_000),
101
+ });
102
+ if (!res.ok) {
103
+ warn(`ocra Cloud did not take the review's counts (HTTP ${res.status})`);
104
+ return false;
105
+ }
106
+ return true;
107
+ }
108
+ catch (error) {
109
+ warn(`could not send the review's counts to ocra Cloud: ${error instanceof Error ? error.name : "error"}`);
110
+ return false;
111
+ }
112
+ }
113
+ /** The default models chosen on the web, for a review that configures none. */
114
+ export async function defaultModels(deps) {
115
+ const session = await cloudSession(deps);
116
+ if (!session)
117
+ return {};
118
+ const res = await deps.fetch(`${session.server}/api/preferences/models`, {
119
+ headers: { authorization: `Bearer ${session.access_token}`, "user-agent": `ocra/${VERSION}` },
120
+ signal: AbortSignal.timeout(15_000),
121
+ });
122
+ if (!res.ok)
123
+ return {};
124
+ const body = (await res.json());
125
+ const chains = {};
126
+ for (const tier of ["top", "standard", "light"]) {
127
+ const list = body.models?.[tier];
128
+ if (Array.isArray(list) && list.every((m) => typeof m === "string" && m.startsWith("ocra-"))) {
129
+ if (list.length > 0)
130
+ chains[tier] = list;
131
+ }
132
+ }
133
+ return chains;
134
+ }
135
+ //# sourceMappingURL=cloud-upload.js.map
@@ -1,6 +1,8 @@
1
1
  import { type OcraPlugin } from "@open-cr-agent/core";
2
+ import type { CloudDeps } from "../cloud.js";
2
3
  import type { ReviewArgs } from "./args.js";
3
4
  import { type Output } from "./progress.js";
5
+ import type { RuntimeLoaders } from "./runtimes.js";
4
6
  export declare const SESSIONS_DIR = ".ocra/sessions";
5
7
  export declare const EXIT: {
6
8
  readonly ok: 0;
@@ -14,10 +16,12 @@ export interface ReviewDeps {
14
16
  cwd: string;
15
17
  env: Readonly<Record<string, string | undefined>>;
16
18
  builtinPlugins: readonly OcraPlugin[];
19
+ runtimes: RuntimeLoaders;
17
20
  writeFile(path: string, content: string): Promise<void>;
18
21
  now(): number;
19
22
  heartbeatMs: number;
20
23
  fetch?: typeof fetch;
24
+ cloud?: CloudDeps;
21
25
  onInterrupt?(handler: () => void): () => void;
22
26
  }
23
27
  export declare function reviewCommand(args: ReviewArgs, io: {
@@ -1,15 +1,20 @@
1
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";
2
+ import { agentsMdReviewerPlugin, correctnessReviewerPlugin, coverageGaps, docsReviewerPlugin, performanceReviewerPlugin, review, securityReviewerPlugin, sessionJsonlPlugin, startPlugins, } from "@open-cr-agent/core";
3
+ import { defaultSelectionPolicy, newRunId, previewReview, toPlanOutput, } from "@open-cr-agent/core/internal";
4
4
  import { githubPlugin } from "@open-cr-agent/vcs-github";
5
5
  import { gitlabPlugin } from "@open-cr-agent/vcs-gitlab";
6
- import { findRepositoryRoot, localGitPlugin } from "@open-cr-agent/vcs-local";
6
+ import { localGitPlugin } from "@open-cr-agent/vcs-local";
7
+ import { findRepositoryRoot } from "@open-cr-agent/vcs-local/internal";
7
8
  import { VERSION } from "../version.js";
9
+ import { withCloudProviders } from "./cloud-providers.js";
10
+ import { cloudEnabled, defaultModels, repoHash, uploadOf, uploadReview } from "./cloud-upload.js";
8
11
  import { ConfigError } from "./config.js";
9
12
  import { renderPlan } from "./plan-render.js";
10
13
  import { ProgressPrinter } from "./progress.js";
14
+ import { configHash, requestedSampling } from "./provenance.js";
11
15
  import { renderJson, renderText, safeJson } from "./render.js";
12
16
  import { renderSarif } from "./sarif.js";
17
+ import { loadSarifLogs } from "./sarif-input.js";
13
18
  import { localTarget, mergeRequestTarget, pullRequestTarget } from "./target.js";
14
19
  import { forTerminal } from "./terminal.js";
15
20
  export const SESSIONS_DIR = ".ocra/sessions";
@@ -18,7 +23,6 @@ export const BUILTIN_PLUGINS = [
18
23
  localGitPlugin,
19
24
  githubPlugin,
20
25
  gitlabPlugin,
21
- opencodeRuntimePlugin,
22
26
  correctnessReviewerPlugin,
23
27
  securityReviewerPlugin,
24
28
  performanceReviewerPlugin,
@@ -30,17 +34,20 @@ export async function reviewCommand(args, io, deps) {
30
34
  const root = await findRepositoryRoot(deps.cwd);
31
35
  const warn = (message) => io.err.write(`[ocra] Warning: ${forTerminal(message)}\n`);
32
36
  const ignoreRepoConfig = args.ignoreRepoConfig === true;
37
+ const configFile = args.configFile ? resolve(deps.cwd, args.configFile) : undefined;
38
+ const localArgs = configFile ? { ...args, configFile } : args;
33
39
  const target = args.pullRequest
34
- ? await pullRequestTarget(args.pullRequest, deps.cwd, root, deps.env, warn, deps.fetch, ignoreRepoConfig)
40
+ ? await pullRequestTarget(args.pullRequest, deps.cwd, root, deps.env, warn, deps.fetch, ignoreRepoConfig, configFile)
35
41
  : args.mergeRequest
36
- ? await mergeRequestTarget(args.mergeRequest, deps.cwd, root, deps.env, warn, deps.fetch, ignoreRepoConfig)
37
- : await localTarget(args, deps.cwd, root, deps.env, warn, deps.fetch);
42
+ ? await mergeRequestTarget(args.mergeRequest, deps.cwd, root, deps.env, warn, deps.fetch, ignoreRepoConfig, configFile)
43
+ : await localTarget(localArgs, deps.cwd, root, deps.env, warn, deps.fetch);
38
44
  const { config } = target;
39
- const session = { dir: join(root, SESSIONS_DIR), id: newSessionId() };
40
- // A plan calls no model and writes no session log.
45
+ const session = { dir: join(root, SESSIONS_DIR), id: newRunId() };
46
+ // A plan calls no model, writes no session log and imports no runtime: it
47
+ // works without the optional runtime-opencode.
41
48
  const builtins = args.plan
42
49
  ? deps.builtinPlugins.filter((p) => p.name !== sessionJsonlPlugin.name)
43
- : deps.builtinPlugins;
50
+ : [...deps.builtinPlugins, ...(await builtinRuntime(deps.runtimes, config.runtime))];
44
51
  const registry = await startPlugins([...builtins, ...target.plugins], {
45
52
  settings: args.plan
46
53
  ? config.pluginSettings
@@ -55,6 +62,8 @@ export async function reviewCommand(args, io, deps) {
55
62
  vcs,
56
63
  reviewers: registry.reviewers,
57
64
  reviewerOverrides: overrides,
65
+ effort: config.effort,
66
+ roles: config.roles,
58
67
  rules: [...registry.rules, ...config.rules],
59
68
  selection: { ...defaultSelectionPolicy, include: config.include, exclude: config.exclude },
60
69
  ...(target.readTrusted ? { readTrusted: target.readTrusted } : {}),
@@ -68,10 +77,23 @@ export async function reviewCommand(args, io, deps) {
68
77
  await deps.writeFile(resolve(deps.cwd, args.output), rendered);
69
78
  return EXIT.ok;
70
79
  }
80
+ const sarif = await loadSarifLogs(args.importSarif ?? [], deps.cwd);
81
+ const sampling = requestedSampling(config, args);
82
+ const cloudDeps = deps.cloud;
83
+ const signedIn = cloudDeps !== undefined && (await cloudEnabled(cloudDeps));
84
+ let models = config.models;
85
+ if (cloudDeps && signedIn && Object.values(models).every((chain) => !chain?.length)) {
86
+ models = await defaultModels(cloudDeps);
87
+ if (Object.keys(models).length > 0) {
88
+ io.err.write("[ocra] No models configured: using your default models from ocra Cloud\n");
89
+ }
90
+ }
91
+ const cloud = await withCloudProviders(models, config.providers, deps.env, cloudDeps, warn);
71
92
  const runtime = registry.createRuntime(config.runtime, {
72
- models: config.models,
73
- env: deps.env,
74
- providers: config.providers,
93
+ models,
94
+ env: cloud.env,
95
+ providers: cloud.providers,
96
+ ...(Object.keys(sampling).length > 0 ? { sampling } : {}),
75
97
  });
76
98
  const progress = new ProgressPrinter(io.err, { heartbeatMs: deps.heartbeatMs, now: deps.now });
77
99
  const interrupt = new AbortController();
@@ -80,14 +102,18 @@ export async function reviewCommand(args, io, deps) {
80
102
  interrupt.abort();
81
103
  });
82
104
  let report;
105
+ const started = deps.now();
83
106
  try {
84
- report = await runReview({
107
+ report = await review({
108
+ runId: session.id,
85
109
  signal: interrupt.signal,
86
110
  ...runOptions(config),
87
111
  reviewerOverrides: overrides,
112
+ provenance: { ocraVersion: VERSION, configHash: configHash(config, args), sampling },
88
113
  ...(args.maxCostUsd !== undefined ? { maxCostUsd: args.maxCostUsd } : {}),
89
114
  ...(args.ultra ? { ultra: true } : {}),
90
115
  ...(args.full ? { fullReview: true } : {}),
116
+ ...(sarif.length > 0 ? { sarif } : {}),
91
117
  ...(target.readTrusted ? { readTrusted: target.readTrusted } : {}),
92
118
  vcs,
93
119
  runtime,
@@ -135,11 +161,25 @@ export async function reviewCommand(args, io, deps) {
135
161
  }
136
162
  io.err.write(`[ocra] Published the review to the ${target.publishesTo ?? "change request"}\n`);
137
163
  }
164
+ if (signedIn && cloudDeps && !args.noUpload) {
165
+ const source = args.pullRequest ? "github" : args.mergeRequest ? "gitlab" : "local";
166
+ const upload = uploadOf(report, source, await repoHash(root, cloudDeps.credentialsPath), deps.now() - started);
167
+ if (await uploadReview(upload, cloudDeps, warn)) {
168
+ io.err.write("[ocra] Sent this review's counts to ocra Cloud (--no-upload to skip)\n");
169
+ }
170
+ }
138
171
  return exitCode(report, io.err);
139
172
  }
173
+ // A runtime not built in comes from a plugin of the configuration.
174
+ async function builtinRuntime(runtimes, name) {
175
+ const load = Object.hasOwn(runtimes, name) ? runtimes[name] : undefined;
176
+ return load ? [await load()] : [];
177
+ }
140
178
  function runOptions(config) {
141
179
  const options = {
142
180
  selection: { ...defaultSelectionPolicy, include: config.include, exclude: config.exclude },
181
+ effort: config.effort,
182
+ roles: config.roles,
143
183
  };
144
184
  if (config.concurrency !== undefined)
145
185
  options.concurrency = config.concurrency;
@@ -1,12 +1,36 @@
1
- import { type CustomProvider, type ModelChains, type RepoRule } from "@open-cr-agent/core";
1
+ import { type CustomProvider, type ModelChains, OcraError, type RepoRule, type TierEfforts } from "@open-cr-agent/core";
2
2
  import { z } from "zod";
3
3
  export declare const CONFIG_PATH = ".ocra/config.json";
4
4
  declare const configSchema: z.ZodObject<{
5
+ $schema: z.ZodOptional<z.ZodString>;
5
6
  models: z.ZodDefault<z.ZodObject<{
6
7
  top: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<string[], string | string[]>>>;
7
8
  standard: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<string[], string | string[]>>>;
8
9
  light: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<string[], string | string[]>>>;
9
10
  }, z.core.$strict>>;
11
+ effort: z.ZodDefault<z.ZodObject<{
12
+ top: z.ZodOptional<z.ZodEnum<{
13
+ high: "high";
14
+ low: "low";
15
+ medium: "medium";
16
+ minimal: "minimal";
17
+ none: "none";
18
+ }>>;
19
+ standard: z.ZodOptional<z.ZodEnum<{
20
+ high: "high";
21
+ low: "low";
22
+ medium: "medium";
23
+ minimal: "minimal";
24
+ none: "none";
25
+ }>>;
26
+ light: z.ZodOptional<z.ZodEnum<{
27
+ high: "high";
28
+ low: "low";
29
+ medium: "medium";
30
+ minimal: "minimal";
31
+ none: "none";
32
+ }>>;
33
+ }, z.core.$strict>>;
10
34
  concurrency: z.ZodOptional<z.ZodNumber>;
11
35
  taskTimeoutMinutes: z.ZodOptional<z.ZodNumber>;
12
36
  runTimeoutMinutes: z.ZodOptional<z.ZodNumber>;
@@ -14,13 +38,20 @@ declare const configSchema: z.ZodObject<{
14
38
  judge: z.ZodOptional<z.ZodBoolean>;
15
39
  maxCostUsd: z.ZodOptional<z.ZodNumber>;
16
40
  maxTasks: z.ZodOptional<z.ZodNumber>;
41
+ sampling: z.ZodOptional<z.ZodObject<{
42
+ temperature: z.ZodOptional<z.ZodNumber>;
43
+ seed: z.ZodOptional<z.ZodNumber>;
44
+ }, z.core.$strict>>;
17
45
  github: z.ZodDefault<z.ZodObject<{
18
46
  requestChanges: z.ZodOptional<z.ZodBoolean>;
19
47
  botLogin: z.ZodOptional<z.ZodString>;
20
48
  }, z.core.$strict>>;
21
49
  include: z.ZodDefault<z.ZodArray<z.ZodString>>;
22
50
  exclude: z.ZodDefault<z.ZodArray<z.ZodString>>;
23
- runtime: z.ZodDefault<z.ZodString>;
51
+ runtime: z.ZodDefault<z.ZodUnion<[z.ZodEnum<{
52
+ direct: "direct";
53
+ opencode: "opencode";
54
+ }>, z.ZodString]>>;
24
55
  plugins: z.ZodDefault<z.ZodArray<z.ZodString>>;
25
56
  reviewers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
26
57
  enabled: z.ZodOptional<z.ZodBoolean>;
@@ -29,6 +60,26 @@ declare const configSchema: z.ZodObject<{
29
60
  lite: "lite";
30
61
  trivial: "trivial";
31
62
  }>>;
63
+ effort: z.ZodOptional<z.ZodEnum<{
64
+ high: "high";
65
+ low: "low";
66
+ medium: "medium";
67
+ minimal: "minimal";
68
+ none: "none";
69
+ }>>;
70
+ }, z.core.$strict>>>;
71
+ roles: z.ZodDefault<z.ZodRecord<z.ZodEnum<{
72
+ helper: "helper";
73
+ judge: "judge";
74
+ verifier: "verifier";
75
+ }> & z.core.$partial, z.ZodObject<{
76
+ effort: z.ZodOptional<z.ZodEnum<{
77
+ high: "high";
78
+ low: "low";
79
+ medium: "medium";
80
+ minimal: "minimal";
81
+ none: "none";
82
+ }>>;
32
83
  }, z.core.$strict>>>;
33
84
  pluginSettings: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
34
85
  providers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
@@ -40,21 +91,32 @@ declare const configSchema: z.ZodObject<{
40
91
  output: z.ZodNumber;
41
92
  cachedInput: z.ZodOptional<z.ZodNumber>;
42
93
  }, z.core.$strict>>;
94
+ effort: z.ZodOptional<z.ZodEnum<{
95
+ openai: "openai";
96
+ openrouter: "openrouter";
97
+ }>>;
43
98
  }, z.core.$strict>>>;
44
99
  extends: z.ZodOptional<z.ZodString>;
45
100
  }, z.core.$strict>;
46
- export type CliConfig = Omit<z.infer<typeof configSchema>, "models" | "providers"> & {
101
+ export declare const CONFIG_SCHEMA_ID = "https://ocra.majincheng.com/schema/config.v1.json";
102
+ export declare function configJsonSchema(): Record<string, unknown>;
103
+ export type CliConfig = Omit<z.infer<typeof configSchema>, "models" | "effort" | "providers"> & {
47
104
  models: ModelChains;
105
+ effort: TierEfforts;
48
106
  providers: Record<string, CustomProvider>;
49
107
  rules: RepoRule[];
50
108
  };
51
109
  export interface LoadOptions {
52
110
  repository: boolean;
53
111
  read?: (path: string) => Promise<string | undefined>;
112
+ file?: string;
54
113
  fetch?: typeof fetch;
55
114
  warn?: (message: string) => void;
56
115
  }
57
- export declare class ConfigError extends Error {
116
+ export declare class ConfigError extends OcraError {
117
+ constructor(message: string, options?: {
118
+ cause?: unknown;
119
+ });
58
120
  }
59
121
  export declare function loadConfig(root: string, env: Readonly<Record<string, string | undefined>>, options?: LoadOptions): Promise<CliConfig>;
60
122
  export {};