@open-cr-agent/cli 0.2.0 → 0.3.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/dist/memory.js CHANGED
@@ -2,8 +2,8 @@ import { constants, existsSync } from "node:fs";
2
2
  import { lstat, mkdir, open, readdir, readFile } from "node:fs/promises";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { parseArgs } from "node:util";
5
- import { MEMORY_PATH, parseMemory, serializeMemory, } from "@open-cr-agent/core";
6
- import { findRepositoryRoot } from "@open-cr-agent/vcs-local";
5
+ import { MEMORY_PATH, parseMemory, serializeMemory } from "@open-cr-agent/core/internal";
6
+ import { findRepositoryRoot } from "@open-cr-agent/vcs-local/internal";
7
7
  import { UsageError } from "./review/args.js";
8
8
  import { SESSIONS_DIR } from "./review/command.js";
9
9
  import { forTerminal } from "./review/terminal.js";
@@ -0,0 +1,47 @@
1
+ import { type Severity, type Verification } from "@open-cr-agent/core";
2
+ import type { Output } from "./review/progress.js";
3
+ export declare const METRICS_VERSION = 1;
4
+ export declare const METRICS_USAGE = "Usage: ocra metrics [options]\n\nCounts over the finished reviews in .ocra/sessions: runs, cost, findings,\nwhat reviewers later fixed or dismissed, per reviewer. Reads each session's\nreport.json (report version 1); nothing calls a model.\n\nOptions:\n --sessions <dir> Sessions directory (default: .ocra/sessions of this repository)\n --since <date> Only runs started at or after this ISO 8601 date\n --format <text|json> Output format (default: text); the JSON carries \"version\": 1\n -h, --help Show help\n";
5
+ export interface ReviewerMetrics {
6
+ tasks: number;
7
+ failedTasks: number;
8
+ findings: number;
9
+ costUsd: number;
10
+ fixed: number;
11
+ dismissed: number;
12
+ acceptanceRate: number | null;
13
+ }
14
+ export interface Metrics {
15
+ version: typeof METRICS_VERSION;
16
+ sessionsDir: string;
17
+ since?: string;
18
+ runs: {
19
+ total: number;
20
+ byVerdict: Record<string, number>;
21
+ incomplete: number;
22
+ unreadable: number;
23
+ };
24
+ cost: {
25
+ usd: number;
26
+ inputTokens: number;
27
+ outputTokens: number;
28
+ usdPerRun: number | null;
29
+ };
30
+ findings: {
31
+ reported: number;
32
+ unique: number;
33
+ bySeverity: Record<Severity, number>;
34
+ byVerification: Record<Verification, number>;
35
+ };
36
+ outcomes: {
37
+ fixed: number;
38
+ dismissed: number;
39
+ acceptanceRate: number | null;
40
+ };
41
+ reviewers: Record<string, ReviewerMetrics>;
42
+ }
43
+ export declare function metricsCommand(argv: string[], out: Output, cwd: string): Promise<number>;
44
+ export declare function sessionStart(id: string): Date | undefined;
45
+ export declare function collectMetrics(sessionsDir: string, since?: Date): Promise<Metrics>;
46
+ export declare function renderMetrics(m: Metrics): string;
47
+ //# sourceMappingURL=metrics.d.ts.map
@@ -0,0 +1,227 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import { parseArgs } from "node:util";
4
+ import { reportOutputSchema, } from "@open-cr-agent/core";
5
+ import { findRepositoryRoot } from "@open-cr-agent/vcs-local/internal";
6
+ import { UsageError } from "./review/args.js";
7
+ import { SESSIONS_DIR } from "./review/command.js";
8
+ import { forTerminal } from "./review/terminal.js";
9
+ export const METRICS_VERSION = 1;
10
+ export const METRICS_USAGE = `Usage: ocra metrics [options]
11
+
12
+ Counts over the finished reviews in ${SESSIONS_DIR}: runs, cost, findings,
13
+ what reviewers later fixed or dismissed, per reviewer. Reads each session's
14
+ report.json (report version 1); nothing calls a model.
15
+
16
+ Options:
17
+ --sessions <dir> Sessions directory (default: ${SESSIONS_DIR} of this repository)
18
+ --since <date> Only runs started at or after this ISO 8601 date
19
+ --format <text|json> Output format (default: text); the JSON carries "version": ${METRICS_VERSION}
20
+ -h, --help Show help
21
+ `;
22
+ export async function metricsCommand(argv, out, cwd) {
23
+ const { values } = parseArgs({
24
+ args: argv,
25
+ allowPositionals: false,
26
+ strict: true,
27
+ options: {
28
+ sessions: { type: "string" },
29
+ since: { type: "string" },
30
+ format: { type: "string" },
31
+ help: { type: "boolean", short: "h" },
32
+ },
33
+ });
34
+ if (values.help) {
35
+ out.write(METRICS_USAGE);
36
+ return 0;
37
+ }
38
+ const format = values.format ?? "text";
39
+ if (format !== "text" && format !== "json")
40
+ throw new UsageError("--format must be text or json");
41
+ const since = values.since === undefined ? undefined : parseSince(values.since);
42
+ const sessionsDir = values.sessions
43
+ ? resolve(cwd, values.sessions)
44
+ : join(await findRepositoryRoot(cwd), SESSIONS_DIR);
45
+ const metrics = await collectMetrics(sessionsDir, since);
46
+ out.write(format === "json" ? `${JSON.stringify(metrics, null, 2)}\n` : renderMetrics(metrics));
47
+ return 0;
48
+ }
49
+ function parseSince(text) {
50
+ const date = new Date(text);
51
+ if (Number.isNaN(date.getTime()))
52
+ throw new UsageError(`--since needs an ISO 8601 date, not "${text}"`);
53
+ return date;
54
+ }
55
+ // A session directory is named by its run id, whose first part is the start
56
+ // time (newRunId in core); that is what --since compares against.
57
+ export function sessionStart(id) {
58
+ const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z/.exec(id);
59
+ if (!m)
60
+ return undefined;
61
+ const [, y, mo, d, h, mi, s] = m;
62
+ return new Date(`${y}-${mo}-${d}T${h}:${mi}:${s}Z`);
63
+ }
64
+ export async function collectMetrics(sessionsDir, since) {
65
+ const names = (await readdir(sessionsDir).catch(() => []))
66
+ .filter((n) => !n.startsWith("."))
67
+ .filter((n) => {
68
+ const start = sessionStart(n);
69
+ return since === undefined || (start !== undefined && start >= since);
70
+ })
71
+ .sort();
72
+ const reports = [];
73
+ let unreadable = 0;
74
+ for (const name of names) {
75
+ const report = await readReport(join(sessionsDir, name, "report.json"));
76
+ if (report)
77
+ reports.push(report);
78
+ else
79
+ unreadable += 1;
80
+ }
81
+ return aggregate(reports, {
82
+ sessionsDir,
83
+ unreadable,
84
+ ...(since ? { since: since.toISOString() } : {}),
85
+ });
86
+ }
87
+ async function readReport(path) {
88
+ let data;
89
+ try {
90
+ data = JSON.parse(await readFile(path, "utf8"));
91
+ }
92
+ catch {
93
+ return undefined;
94
+ }
95
+ const parsed = reportOutputSchema.safeParse(data);
96
+ return parsed.success ? parsed.data : undefined;
97
+ }
98
+ function aggregate(reports, meta) {
99
+ const byVerdict = {};
100
+ const bySeverity = { critical: 0, warning: 0, suggestion: 0 };
101
+ const byVerification = { confirmed: 0, uncertain: 0, unchecked: 0 };
102
+ const reviewers = new Map();
103
+ const reviewerOf = new Map();
104
+ const unique = new Set();
105
+ const fixed = new Set();
106
+ const dismissed = new Set();
107
+ let incomplete = 0;
108
+ let reported = 0;
109
+ let usd = 0;
110
+ let inputTokens = 0;
111
+ let outputTokens = 0;
112
+ const forReviewer = (id) => {
113
+ let r = reviewers.get(id);
114
+ if (!r) {
115
+ r = {
116
+ tasks: 0,
117
+ failedTasks: 0,
118
+ findings: 0,
119
+ costUsd: 0,
120
+ fixed: 0,
121
+ dismissed: 0,
122
+ acceptanceRate: null,
123
+ };
124
+ reviewers.set(id, r);
125
+ }
126
+ return r;
127
+ };
128
+ for (const report of reports) {
129
+ byVerdict[report.verdict] = (byVerdict[report.verdict] ?? 0) + 1;
130
+ if (report.coverage.some((c) => c.status === "failed" || c.status === "unreviewed"))
131
+ incomplete += 1;
132
+ usd += report.usage.costUsd;
133
+ inputTokens += report.usage.inputTokens;
134
+ outputTokens += report.usage.outputTokens;
135
+ for (const task of report.tasks) {
136
+ const r = forReviewer(task.reviewer);
137
+ r.tasks += 1;
138
+ if (task.status === "failed" || task.status === "timed_out")
139
+ r.failedTasks += 1;
140
+ r.costUsd += task.usage.costUsd;
141
+ }
142
+ for (const finding of report.findings) {
143
+ reported += 1;
144
+ unique.add(finding.fingerprint);
145
+ bySeverity[finding.severity] += 1;
146
+ byVerification[finding.verification] += 1;
147
+ forReviewer(finding.reviewer).findings += 1;
148
+ if (!reviewerOf.has(finding.fingerprint))
149
+ reviewerOf.set(finding.fingerprint, finding.reviewer);
150
+ }
151
+ for (const f of report.rereview?.fixed ?? [])
152
+ fixed.add(f.fingerprint);
153
+ for (const f of report.rereview?.dismissed ?? [])
154
+ dismissed.add(f.fingerprint);
155
+ }
156
+ // A fingerprint can be dismissed in one review and gone in a later one:
157
+ // the reviewer's decision stands.
158
+ for (const fp of dismissed)
159
+ fixed.delete(fp);
160
+ for (const fp of fixed) {
161
+ const reviewer = reviewerOf.get(fp);
162
+ if (reviewer)
163
+ forReviewer(reviewer).fixed += 1;
164
+ }
165
+ for (const fp of dismissed) {
166
+ const reviewer = reviewerOf.get(fp);
167
+ if (reviewer)
168
+ forReviewer(reviewer).dismissed += 1;
169
+ }
170
+ for (const r of reviewers.values())
171
+ r.acceptanceRate = rate(r.fixed, r.dismissed);
172
+ return {
173
+ version: METRICS_VERSION,
174
+ sessionsDir: meta.sessionsDir,
175
+ ...(meta.since ? { since: meta.since } : {}),
176
+ runs: { total: reports.length, byVerdict, incomplete, unreadable: meta.unreadable },
177
+ cost: {
178
+ usd,
179
+ inputTokens,
180
+ outputTokens,
181
+ usdPerRun: reports.length > 0 ? usd / reports.length : null,
182
+ },
183
+ findings: { reported, unique: unique.size, bySeverity, byVerification },
184
+ outcomes: {
185
+ fixed: fixed.size,
186
+ dismissed: dismissed.size,
187
+ acceptanceRate: rate(fixed.size, dismissed.size),
188
+ },
189
+ reviewers: Object.fromEntries([...reviewers].sort(([a], [b]) => a.localeCompare(b))),
190
+ };
191
+ }
192
+ function rate(fixed, dismissed) {
193
+ return fixed + dismissed > 0 ? fixed / (fixed + dismissed) : null;
194
+ }
195
+ const percent = (value) => (value === null ? "n/a" : `${Math.round(value * 100)}%`);
196
+ const dollars = (value) => (value === null ? "n/a" : `$${value.toFixed(2)}`);
197
+ export function renderMetrics(m) {
198
+ const lines = [
199
+ `Runs: ${m.runs.total}${m.since ? ` since ${m.since}` : ""} (${m.runs.incomplete} incomplete, ${m.runs.unreadable} unreadable)`,
200
+ ` by verdict: ${entries(m.runs.byVerdict)}`,
201
+ `Cost: ${dollars(m.cost.usd)} total, ${dollars(m.cost.usdPerRun)} per run · ${m.cost.inputTokens} in / ${m.cost.outputTokens} out tokens`,
202
+ `Findings: ${m.findings.reported} reported, ${m.findings.unique} unique`,
203
+ ` by severity: ${entries(m.findings.bySeverity)}`,
204
+ ` by verification: ${entries(m.findings.byVerification)}`,
205
+ `Outcomes: ${m.outcomes.fixed} fixed, ${m.outcomes.dismissed} dismissed, acceptance ${percent(m.outcomes.acceptanceRate)}`,
206
+ "",
207
+ "Reviewer Tasks Failed Findings Cost Fixed Dismissed Acceptance",
208
+ ];
209
+ for (const [id, r] of Object.entries(m.reviewers)) {
210
+ lines.push([
211
+ id.padEnd(18),
212
+ String(r.tasks).padStart(5),
213
+ String(r.failedTasks).padStart(7),
214
+ String(r.findings).padStart(9),
215
+ dollars(r.costUsd).padStart(9),
216
+ String(r.fixed).padStart(6),
217
+ String(r.dismissed).padStart(10),
218
+ percent(r.acceptanceRate).padStart(11),
219
+ ].join(" "));
220
+ }
221
+ return `${forTerminal(lines.join("\n"))}\n`;
222
+ }
223
+ function entries(record) {
224
+ const parts = Object.entries(record).filter(([, n]) => n > 0);
225
+ return parts.length === 0 ? "none" : parts.map(([k, n]) => `${k} ${n}`).join(", ");
226
+ }
227
+ //# sourceMappingURL=metrics.js.map
@@ -1,3 +1,4 @@
1
+ import { OcraError } from "@open-cr-agent/core";
1
2
  import type { LocalTarget } from "@open-cr-agent/vcs-local";
2
3
  export type OutputFormat = "text" | "json" | "sarif";
3
4
  export interface PullRequestTarget {
@@ -22,9 +23,14 @@ export interface ReviewArgs {
22
23
  ultra?: true;
23
24
  full?: true;
24
25
  plan?: true;
26
+ importSarif?: string[];
27
+ configFile?: string;
28
+ temperature?: number;
29
+ seed?: number;
25
30
  }
26
- export declare class UsageError extends Error {
31
+ export declare class UsageError extends OcraError {
32
+ constructor(message: string);
27
33
  }
28
- export declare const REVIEW_USAGE = "Usage: ocra review [options]\n\nReviews uncommitted changes by default.\n\nOptions:\n --from <ref> Review changes on --to since it diverged from <ref>\n --to <ref> End of the range (default: HEAD)\n --commit <sha> Review a single commit\n --pr <number> Review a GitHub pull request (needs GITHUB_TOKEN)\n --repo <owner/name> Repository of --pr (default: GITHUB_REPOSITORY or origin)\n --mr <iid> Review a GitLab merge request (needs GITLAB_TOKEN)\n --project <id|path> Project of --mr (default: CI_PROJECT_ID or origin)\n --publish With --pr or --mr: post the review to it\n --full With --pr or --mr: review every file, not only what\n changed since the previous review\n --format <format> text (default), json, or sarif (SARIF 2.1.0; not with --plan)\n --output <file> Write the result to a file instead of stdout\n --reviewers <ids> Run only these reviewers (comma-separated)\n --max-cost-usd <n> Spend limit: reviews use 80%, verification and judging the rest\n --plan Show files, bundles, review tasks and prompt sizes; call no model\n --ultra Favor recall: all reviewers at every tier, two samples each (about 2x cost)\n --no-repo-config Ignore .ocra/config.json and its plugins (for untrusted\n code); models come from OCRA_MODEL_* variables\n -h, --help Show help\n";
34
+ export declare const REVIEW_USAGE = "Usage: ocra review [options]\n\nReviews uncommitted changes by default.\n\nOptions:\n --from <ref> Review changes on --to since it diverged from <ref>\n --to <ref> End of the range (default: HEAD)\n --commit <sha> Review a single commit\n --pr <number> Review a GitHub pull request (needs GITHUB_TOKEN)\n --repo <owner/name> Repository of --pr (default: GITHUB_REPOSITORY or origin)\n --mr <iid> Review a GitLab merge request (needs GITLAB_TOKEN)\n --project <id|path> Project of --mr (default: CI_PROJECT_ID or origin)\n --publish With --pr or --mr: post the review to it\n --full With --pr or --mr: review every file, not only what\n changed since the previous review\n --format <format> text (default), json, or sarif (SARIF 2.1.0; not with --plan)\n --output <file> Write the result to a file instead of stdout\n --reviewers <ids> Run only these reviewers (comma-separated)\n --max-cost-usd <n> Spend limit: reviews use 80%, verification and judging the rest\n --import-sarif <file> Add the results of a SARIF 2.1.0 log (Semgrep, CodeQL, \u2026)\n that fall on the change; repeatable; not with --plan\n --plan Show files, bundles, review tasks and prompt sizes; call no model\n --ultra Favor recall: all reviewers at every tier, two samples each (about 2x cost)\n --temperature <n> Sampling temperature, 0 to 2 (default: the provider's, or sampling in config)\n --seed <n> Sampling seed, where the runtime and provider support one\n --config <file> Read this configuration file instead of the repository's\n .ocra/config.json; it applies with --no-repo-config too\n --no-repo-config Ignore .ocra/config.json and its plugins (for untrusted\n code); models come from OCRA_MODEL_* variables or --config\n -h, --help Show help\n";
29
35
  export declare function parseReviewArgs(argv: string[]): ReviewArgs | "help";
30
36
  //# sourceMappingURL=args.d.ts.map
@@ -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,16 @@ 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
27
38
  -h, --help Show help
28
39
  `;
29
40
  export function parseReviewArgs(argv) {
@@ -79,6 +90,30 @@ export function parseReviewArgs(argv) {
79
90
  throw new UsageError(`--max-cost-usd must be a positive number, got ${values["max-cost-usd"]}`);
80
91
  args.maxCostUsd = max;
81
92
  }
93
+ if (values.temperature !== undefined) {
94
+ const temperature = Number(values.temperature);
95
+ if (values.temperature.trim() === "" || !(temperature >= 0 && temperature <= 2)) {
96
+ throw new UsageError(`--temperature must be a number from 0 to 2, got ${values.temperature}`);
97
+ }
98
+ args.temperature = temperature;
99
+ }
100
+ if (values.seed !== undefined) {
101
+ const seed = Number(values.seed);
102
+ if (!/^\d+$/.test(values.seed) || seed > 2 ** 31 - 1) {
103
+ throw new UsageError(`--seed must be a whole number from 0 to 2147483647, got ${values.seed}`);
104
+ }
105
+ args.seed = seed;
106
+ }
107
+ if (values.config !== undefined) {
108
+ if (values.config === "")
109
+ throw new UsageError("--config needs a file");
110
+ args.configFile = values.config;
111
+ }
112
+ if (values["import-sarif"] !== undefined && values["import-sarif"].length > 0) {
113
+ if (values.plan)
114
+ throw new UsageError("--import-sarif is not used by --plan");
115
+ args.importSarif = values["import-sarif"];
116
+ }
82
117
  if (values.reviewers !== undefined) {
83
118
  const ids = values.reviewers
84
119
  .split(",")
@@ -102,9 +137,13 @@ function parse(argv) {
102
137
  format: { type: "string" },
103
138
  output: { type: "string" },
104
139
  "no-repo-config": { type: "boolean" },
140
+ config: { type: "string" },
105
141
  reviewers: { type: "string" },
106
142
  "max-cost-usd": { type: "string" },
143
+ "import-sarif": { type: "string", multiple: true },
107
144
  ultra: { type: "boolean" },
145
+ temperature: { type: "string" },
146
+ seed: { type: "string" },
108
147
  full: { type: "boolean" },
109
148
  plan: { type: "boolean" },
110
149
  pr: { type: "string" },
@@ -1,6 +1,7 @@
1
1
  import { type OcraPlugin } from "@open-cr-agent/core";
2
2
  import type { ReviewArgs } from "./args.js";
3
3
  import { type Output } from "./progress.js";
4
+ import type { RuntimeLoaders } from "./runtimes.js";
4
5
  export declare const SESSIONS_DIR = ".ocra/sessions";
5
6
  export declare const EXIT: {
6
7
  readonly ok: 0;
@@ -14,6 +15,7 @@ export interface ReviewDeps {
14
15
  cwd: string;
15
16
  env: Readonly<Record<string, string | undefined>>;
16
17
  builtinPlugins: readonly OcraPlugin[];
18
+ runtimes: RuntimeLoaders;
17
19
  writeFile(path: string, content: string): Promise<void>;
18
20
  now(): number;
19
21
  heartbeatMs: number;
@@ -1,15 +1,18 @@
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";
8
9
  import { ConfigError } from "./config.js";
9
10
  import { renderPlan } from "./plan-render.js";
10
11
  import { ProgressPrinter } from "./progress.js";
12
+ import { configHash, requestedSampling } from "./provenance.js";
11
13
  import { renderJson, renderText, safeJson } from "./render.js";
12
14
  import { renderSarif } from "./sarif.js";
15
+ import { loadSarifLogs } from "./sarif-input.js";
13
16
  import { localTarget, mergeRequestTarget, pullRequestTarget } from "./target.js";
14
17
  import { forTerminal } from "./terminal.js";
15
18
  export const SESSIONS_DIR = ".ocra/sessions";
@@ -18,7 +21,6 @@ export const BUILTIN_PLUGINS = [
18
21
  localGitPlugin,
19
22
  githubPlugin,
20
23
  gitlabPlugin,
21
- opencodeRuntimePlugin,
22
24
  correctnessReviewerPlugin,
23
25
  securityReviewerPlugin,
24
26
  performanceReviewerPlugin,
@@ -30,17 +32,20 @@ export async function reviewCommand(args, io, deps) {
30
32
  const root = await findRepositoryRoot(deps.cwd);
31
33
  const warn = (message) => io.err.write(`[ocra] Warning: ${forTerminal(message)}\n`);
32
34
  const ignoreRepoConfig = args.ignoreRepoConfig === true;
35
+ const configFile = args.configFile ? resolve(deps.cwd, args.configFile) : undefined;
36
+ const localArgs = configFile ? { ...args, configFile } : args;
33
37
  const target = args.pullRequest
34
- ? await pullRequestTarget(args.pullRequest, deps.cwd, root, deps.env, warn, deps.fetch, ignoreRepoConfig)
38
+ ? await pullRequestTarget(args.pullRequest, deps.cwd, root, deps.env, warn, deps.fetch, ignoreRepoConfig, configFile)
35
39
  : 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);
40
+ ? await mergeRequestTarget(args.mergeRequest, deps.cwd, root, deps.env, warn, deps.fetch, ignoreRepoConfig, configFile)
41
+ : await localTarget(localArgs, deps.cwd, root, deps.env, warn, deps.fetch);
38
42
  const { config } = target;
39
- const session = { dir: join(root, SESSIONS_DIR), id: newSessionId() };
40
- // A plan calls no model and writes no session log.
43
+ const session = { dir: join(root, SESSIONS_DIR), id: newRunId() };
44
+ // A plan calls no model, writes no session log and imports no runtime: it
45
+ // works without the optional runtime-opencode.
41
46
  const builtins = args.plan
42
47
  ? deps.builtinPlugins.filter((p) => p.name !== sessionJsonlPlugin.name)
43
- : deps.builtinPlugins;
48
+ : [...deps.builtinPlugins, ...(await builtinRuntime(deps.runtimes, config.runtime))];
44
49
  const registry = await startPlugins([...builtins, ...target.plugins], {
45
50
  settings: args.plan
46
51
  ? config.pluginSettings
@@ -68,10 +73,13 @@ export async function reviewCommand(args, io, deps) {
68
73
  await deps.writeFile(resolve(deps.cwd, args.output), rendered);
69
74
  return EXIT.ok;
70
75
  }
76
+ const sarif = await loadSarifLogs(args.importSarif ?? [], deps.cwd);
77
+ const sampling = requestedSampling(config, args);
71
78
  const runtime = registry.createRuntime(config.runtime, {
72
79
  models: config.models,
73
80
  env: deps.env,
74
81
  providers: config.providers,
82
+ ...(Object.keys(sampling).length > 0 ? { sampling } : {}),
75
83
  });
76
84
  const progress = new ProgressPrinter(io.err, { heartbeatMs: deps.heartbeatMs, now: deps.now });
77
85
  const interrupt = new AbortController();
@@ -81,13 +89,16 @@ export async function reviewCommand(args, io, deps) {
81
89
  });
82
90
  let report;
83
91
  try {
84
- report = await runReview({
92
+ report = await review({
93
+ runId: session.id,
85
94
  signal: interrupt.signal,
86
95
  ...runOptions(config),
87
96
  reviewerOverrides: overrides,
97
+ provenance: { ocraVersion: VERSION, configHash: configHash(config, args), sampling },
88
98
  ...(args.maxCostUsd !== undefined ? { maxCostUsd: args.maxCostUsd } : {}),
89
99
  ...(args.ultra ? { ultra: true } : {}),
90
100
  ...(args.full ? { fullReview: true } : {}),
101
+ ...(sarif.length > 0 ? { sarif } : {}),
91
102
  ...(target.readTrusted ? { readTrusted: target.readTrusted } : {}),
92
103
  vcs,
93
104
  runtime,
@@ -137,6 +148,11 @@ export async function reviewCommand(args, io, deps) {
137
148
  }
138
149
  return exitCode(report, io.err);
139
150
  }
151
+ // A runtime not built in comes from a plugin of the configuration.
152
+ async function builtinRuntime(runtimes, name) {
153
+ const load = Object.hasOwn(runtimes, name) ? runtimes[name] : undefined;
154
+ return load ? [await load()] : [];
155
+ }
140
156
  function runOptions(config) {
141
157
  const options = {
142
158
  selection: { ...defaultSelectionPolicy, include: config.include, exclude: config.exclude },
@@ -1,7 +1,8 @@
1
- import { type CustomProvider, type ModelChains, type RepoRule } from "@open-cr-agent/core";
1
+ import { type CustomProvider, type ModelChains, OcraError, type RepoRule } 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[]>>>;
@@ -14,13 +15,20 @@ declare const configSchema: z.ZodObject<{
14
15
  judge: z.ZodOptional<z.ZodBoolean>;
15
16
  maxCostUsd: z.ZodOptional<z.ZodNumber>;
16
17
  maxTasks: z.ZodOptional<z.ZodNumber>;
18
+ sampling: z.ZodOptional<z.ZodObject<{
19
+ temperature: z.ZodOptional<z.ZodNumber>;
20
+ seed: z.ZodOptional<z.ZodNumber>;
21
+ }, z.core.$strict>>;
17
22
  github: z.ZodDefault<z.ZodObject<{
18
23
  requestChanges: z.ZodOptional<z.ZodBoolean>;
19
24
  botLogin: z.ZodOptional<z.ZodString>;
20
25
  }, z.core.$strict>>;
21
26
  include: z.ZodDefault<z.ZodArray<z.ZodString>>;
22
27
  exclude: z.ZodDefault<z.ZodArray<z.ZodString>>;
23
- runtime: z.ZodDefault<z.ZodString>;
28
+ runtime: z.ZodDefault<z.ZodUnion<[z.ZodEnum<{
29
+ direct: "direct";
30
+ opencode: "opencode";
31
+ }>, z.ZodString]>>;
24
32
  plugins: z.ZodDefault<z.ZodArray<z.ZodString>>;
25
33
  reviewers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
26
34
  enabled: z.ZodOptional<z.ZodBoolean>;
@@ -43,6 +51,8 @@ declare const configSchema: z.ZodObject<{
43
51
  }, z.core.$strict>>>;
44
52
  extends: z.ZodOptional<z.ZodString>;
45
53
  }, z.core.$strict>;
54
+ export declare const CONFIG_SCHEMA_ID = "https://ocra.majincheng.com/schema/config.v1.json";
55
+ export declare function configJsonSchema(): Record<string, unknown>;
46
56
  export type CliConfig = Omit<z.infer<typeof configSchema>, "models" | "providers"> & {
47
57
  models: ModelChains;
48
58
  providers: Record<string, CustomProvider>;
@@ -51,10 +61,14 @@ export type CliConfig = Omit<z.infer<typeof configSchema>, "models" | "providers
51
61
  export interface LoadOptions {
52
62
  repository: boolean;
53
63
  read?: (path: string) => Promise<string | undefined>;
64
+ file?: string;
54
65
  fetch?: typeof fetch;
55
66
  warn?: (message: string) => void;
56
67
  }
57
- export declare class ConfigError extends Error {
68
+ export declare class ConfigError extends OcraError {
69
+ constructor(message: string, options?: {
70
+ cause?: unknown;
71
+ });
58
72
  }
59
73
  export declare function loadConfig(root: string, env: Readonly<Record<string, string | undefined>>, options?: LoadOptions): Promise<CliConfig>;
60
74
  export {};
@@ -1,6 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { RISK_TIERS, } from "@open-cr-agent/core";
3
+ import { OcraError, } from "@open-cr-agent/core";
4
+ import { RISK_TIERS } from "@open-cr-agent/core/internal";
4
5
  import { z } from "zod";
5
6
  import { fetchRemoteConfig, mergeConfig } from "./remote-config.js";
6
7
  export const CONFIG_PATH = ".ocra/config.json";
@@ -41,8 +42,12 @@ const providerSchema = z
41
42
  .refine((models) => Object.keys(models).length > 0, "must list at least one model"),
42
43
  })
43
44
  .strict();
45
+ // The runtimes ocra ships; a plugin may register another name, so the
46
+ // schema lists these for editors and still accepts any other string.
47
+ const BUILTIN_RUNTIMES = ["opencode", "direct"];
44
48
  const configSchema = z
45
49
  .object({
50
+ $schema: z.string().optional(),
46
51
  models: z
47
52
  .object({ top: modelChain, standard: modelChain, light: modelChain })
48
53
  .partial()
@@ -55,6 +60,20 @@ const configSchema = z
55
60
  judge: z.boolean().optional(),
56
61
  maxCostUsd: z.number().positive().optional(),
57
62
  maxTasks: z.number().int().min(1).max(1_000).optional(),
63
+ // Unset: each provider's default. A seed makes sampling repeatable only
64
+ // where the provider supports it.
65
+ sampling: z
66
+ .object({
67
+ temperature: z.number().min(0).max(2),
68
+ seed: z
69
+ .number()
70
+ .int()
71
+ .min(0)
72
+ .max(2 ** 31 - 1),
73
+ })
74
+ .partial()
75
+ .strict()
76
+ .optional(),
58
77
  github: z
59
78
  .object({ requestChanges: z.boolean(), botLogin: z.string().min(1) })
60
79
  .partial()
@@ -62,7 +81,7 @@ const configSchema = z
62
81
  .default({}),
63
82
  include: z.array(z.string().min(1)).default([]),
64
83
  exclude: z.array(z.string().min(1)).default([]),
65
- runtime: z.string().min(1).default("opencode"),
84
+ runtime: z.enum(BUILTIN_RUNTIMES).or(z.string().min(1)).default("opencode"),
66
85
  plugins: z.array(z.string().min(1)).default([]),
67
86
  reviewers: z
68
87
  .record(z.string(), z
@@ -78,20 +97,40 @@ const configSchema = z
78
97
  extends: z.string().min(1).optional(),
79
98
  })
80
99
  .strict();
100
+ export const CONFIG_SCHEMA_ID = "https://ocra.majincheng.com/schema/config.v1.json";
101
+ // JSON Schema (draft 2020-12) of .ocra/config.json, for editors: what a
102
+ // file may say, so defaults are optional and model chains take either form.
103
+ export function configJsonSchema() {
104
+ const generated = z.toJSONSchema(configSchema, { target: "draft-2020-12", io: "input" });
105
+ const { $schema, ...rest } = generated;
106
+ return {
107
+ $schema,
108
+ $id: CONFIG_SCHEMA_ID,
109
+ title: "ocra configuration",
110
+ description: "The .ocra/config.json file of a repository, or a file passed with --config. Unknown keys are rejected.",
111
+ ...rest,
112
+ };
113
+ }
81
114
  const MODEL_ENV = {
82
115
  top: "OCRA_MODEL_TOP",
83
116
  standard: "OCRA_MODEL_STANDARD",
84
117
  light: "OCRA_MODEL_LIGHT",
85
118
  };
86
- export class ConfigError extends Error {
119
+ export class ConfigError extends OcraError {
120
+ constructor(message, options) {
121
+ super("CONFIG_INVALID", message, options);
122
+ this.name = "ConfigError";
123
+ }
87
124
  }
88
125
  // Without the repository's file, only defaults and environment variables
89
126
  // apply: that file can name plugins, and plugins run code.
90
127
  export async function loadConfig(root, env, options = { repository: true }) {
91
128
  const read = options.read ?? ((path) => readConfigFromDisk(root, path));
92
- const { config: parsed, rules } = options.repository
93
- ? await readConfigFile(read, options)
94
- : { config: configSchema.parse({}), rules: [] };
129
+ const { config: parsed, rules } = options.file
130
+ ? await readConfigFile(ownFile(options.file), options, options.file)
131
+ : options.repository
132
+ ? await readConfigFile(read, options, CONFIG_PATH)
133
+ : { config: configSchema.parse({}), rules: [] };
95
134
  const models = {};
96
135
  for (const [tier, chain] of Object.entries(parsed.models)) {
97
136
  if (chain)
@@ -139,6 +178,16 @@ function unpriced(providers, models) {
139
178
  // The user's own checkout, read like any local file (links followed): unlike
140
179
  // the review's reads, which never follow links (vcs-local), this is trusted
141
180
  // configuration, and --no-repo-config skips it for code that is not.
181
+ function ownFile(file) {
182
+ return async () => {
183
+ try {
184
+ return await readFile(file, "utf8");
185
+ }
186
+ catch (error) {
187
+ throw new ConfigError(`cannot read ${file}: ${error.message}`, { cause: error });
188
+ }
189
+ };
190
+ }
142
191
  async function readConfigFromDisk(root, path) {
143
192
  try {
144
193
  return await readFile(join(root, path), "utf8");
@@ -149,7 +198,7 @@ async function readConfigFromDisk(root, path) {
149
198
  throw error;
150
199
  }
151
200
  }
152
- async function readConfigFile(read, options) {
201
+ async function readConfigFile(read, options, label) {
153
202
  const text = await read(CONFIG_PATH);
154
203
  if (text === undefined)
155
204
  return { config: configSchema.parse({}), rules: [] };
@@ -158,11 +207,13 @@ async function readConfigFile(read, options) {
158
207
  data = JSON.parse(text);
159
208
  }
160
209
  catch (error) {
161
- throw new ConfigError(`${CONFIG_PATH} is not valid JSON: ${error.message}`);
210
+ throw new ConfigError(`${label} is not valid JSON: ${error.message}`, {
211
+ cause: error,
212
+ });
162
213
  }
163
214
  const local = configSchema.safeParse(data);
164
215
  if (!local.success)
165
- throw new ConfigError(`${CONFIG_PATH} is invalid: ${z.prettifyError(local.error)}`);
216
+ throw new ConfigError(`${label} is invalid: ${z.prettifyError(local.error)}`);
166
217
  if (!local.data.extends)
167
218
  return { config: local.data, rules: [] };
168
219
  // A shared configuration that cannot be loaded costs its defaults, not the
@@ -1,3 +1,3 @@
1
- import type { ReviewPreview } from "@open-cr-agent/core";
1
+ import type { ReviewPreview } from "@open-cr-agent/core/internal";
2
2
  export declare function renderPlan(preview: ReviewPreview): string;
3
3
  //# sourceMappingURL=plan-render.d.ts.map
@@ -33,7 +33,7 @@ export class ProgressPrinter {
33
33
  function describe(event) {
34
34
  switch (event.type) {
35
35
  case "run_started":
36
- return `Reviewing: ${event.changeRequest.title}`;
36
+ return `Reviewing: ${event.changeRequest.title} · run ${event.runId}`;
37
37
  case "files_selected":
38
38
  return `${event.selected} file(s) selected, ${event.excluded} excluded · risk tier: ${event.tier}`;
39
39
  case "files_bundled":
@@ -0,0 +1,6 @@
1
+ import type { Sampling } from "@open-cr-agent/core";
2
+ import type { ReviewArgs } from "./args.js";
3
+ import type { CliConfig } from "./config.js";
4
+ export declare function requestedSampling(config: CliConfig, args: ReviewArgs): Sampling;
5
+ export declare function configHash(config: CliConfig, args: ReviewArgs): string;
6
+ //# sourceMappingURL=provenance.d.ts.map
@@ -0,0 +1,38 @@
1
+ import { stableHash } from "@open-cr-agent/core/internal";
2
+ // The sampling a review asks for: the configuration's, with --temperature
3
+ // and --seed on top.
4
+ export function requestedSampling(config, args) {
5
+ const temperature = args.temperature ?? config.sampling?.temperature;
6
+ const seed = args.seed ?? config.sampling?.seed;
7
+ return {
8
+ ...(temperature === undefined ? {} : { temperature }),
9
+ ...(seed === undefined ? {} : { seed }),
10
+ };
11
+ }
12
+ // The effective configuration and the flags that change what is reviewed
13
+ // and how, so two runs can tell they were set up alike. Keys are names of
14
+ // environment variables, never their values; a password written into a
15
+ // URL is dropped. Sampling is recorded on its own.
16
+ export function configHash(config, args) {
17
+ const { sampling: _, providers, extends: shared, ...rest } = config;
18
+ return stableHash({
19
+ ...rest,
20
+ ...(shared === undefined ? {} : { extends: withoutCredentials(shared) }),
21
+ providers: Object.fromEntries(Object.entries(providers).map(([id, p]) => [
22
+ id,
23
+ { ...p, baseUrl: withoutCredentials(p.baseUrl) },
24
+ ])),
25
+ flags: { reviewers: args.reviewers, ultra: args.ultra, maxCostUsd: args.maxCostUsd },
26
+ });
27
+ }
28
+ function withoutCredentials(value) {
29
+ if (!URL.canParse(value))
30
+ return value;
31
+ const url = new URL(value);
32
+ if (url.username === "" && url.password === "")
33
+ return value;
34
+ url.username = "";
35
+ url.password = "";
36
+ return url.toString();
37
+ }
38
+ //# sourceMappingURL=provenance.js.map
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  export declare const remoteConfigSchema: z.ZodObject<{
3
+ $schema: z.ZodOptional<z.ZodString>;
3
4
  models: z.ZodOptional<z.ZodRecord<z.ZodEnum<{
4
5
  light: "light";
5
6
  standard: "standard";
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
- import { repoRuleSchema } from "@open-cr-agent/core";
2
+ import { OcraError } from "@open-cr-agent/core";
3
+ import { repoRuleSchema } from "@open-cr-agent/core/internal";
3
4
  import { z } from "zod";
4
5
  const MAX_BYTES = 256 * 1024;
5
6
  const TIMEOUT_MS = 10_000;
@@ -8,6 +9,7 @@ const TIMEOUT_MS = 10_000;
8
9
  // is fetched from outside the repository.
9
10
  export const remoteConfigSchema = z
10
11
  .object({
12
+ $schema: z.string().optional(),
11
13
  models: z.record(z.enum(["top", "standard", "light"]), z.unknown()).optional(),
12
14
  concurrency: z.unknown().optional(),
13
15
  taskTimeoutMinutes: z.unknown().optional(),
@@ -29,7 +31,7 @@ export async function fetchRemoteConfig(spec, fetchImpl = fetch) {
29
31
  const [address = "", fragment] = spec.split("#", 2);
30
32
  const url = new URL(address);
31
33
  if (url.protocol !== "https:")
32
- throw new Error(`extends must be an https URL, got ${url.protocol}`);
34
+ throw new OcraError("CONFIG_INVALID", `extends must be an https URL, got ${url.protocol}`);
33
35
  const pinned = fragment?.startsWith("sha256=")
34
36
  ? fragment.slice("sha256=".length).toLowerCase()
35
37
  : undefined;
@@ -39,21 +41,21 @@ export async function fetchRemoteConfig(spec, fetchImpl = fetch) {
39
41
  redirect: "error",
40
42
  });
41
43
  if (!response.ok)
42
- throw new Error(`${url.href} answered ${response.status}`);
44
+ throw new OcraError("CONFIG_INVALID", `${url.href} answered ${response.status}`);
43
45
  const body = await readLimited(response, MAX_BYTES, url.href);
44
46
  if (pinned !== undefined) {
45
47
  const actual = createHash("sha256").update(body).digest("hex");
46
48
  if (actual !== pinned)
47
- throw new Error(`${url.href} does not match its pinned sha256`);
49
+ throw new OcraError("CONFIG_INVALID", `${url.href} does not match its pinned sha256`);
48
50
  }
49
51
  const parsed = remoteConfigSchema.safeParse(JSON.parse(body));
50
52
  if (!parsed.success)
51
- throw new Error(`${url.href} is invalid: ${z.prettifyError(parsed.error)}`);
53
+ throw new OcraError("CONFIG_INVALID", `${url.href} is invalid: ${z.prettifyError(parsed.error)}`);
52
54
  // A provider decides where the code under review goes: unpinned, whoever
53
55
  // serves the file could send it to an endpoint of their choosing.
54
56
  if (pinned === undefined && Object.keys(parsed.data.providers ?? {}).length > 0) {
55
57
  const digest = createHash("sha256").update(body).digest("hex");
56
- throw new Error(`${url.href} declares providers, which a shared configuration may do only when pinned; check its content, then extend "${address}#sha256=${digest}"`);
58
+ throw new OcraError("CONFIG_INVALID", `${url.href} declares providers, which a shared configuration may do only when pinned; check its content, then extend "${address}#sha256=${digest}"`);
57
59
  }
58
60
  return parsed.data;
59
61
  }
@@ -71,7 +73,7 @@ async function readLimited(response, limit, name) {
71
73
  size += value.byteLength;
72
74
  if (size > limit) {
73
75
  await reader.cancel();
74
- throw new Error(`${name} is larger than 256 KB`);
76
+ throw new OcraError("CONFIG_INVALID", `${name} is larger than 256 KB`);
75
77
  }
76
78
  chunks.push(value);
77
79
  }
@@ -1,4 +1,5 @@
1
- import { type ReviewReport, serializeOutput } from "@open-cr-agent/core";
1
+ import { type ReviewReport } from "@open-cr-agent/core";
2
+ import { serializeOutput } from "@open-cr-agent/core/internal";
2
3
  export declare function renderJson(report: ReviewReport): string;
3
4
  export declare const safeJson: typeof serializeOutput;
4
5
  export declare function renderText(report: ReviewReport, sessionDir?: string): string;
@@ -1,4 +1,5 @@
1
- import { coverageGaps, serializeOutput, toReportOutput, } from "@open-cr-agent/core";
1
+ import { coverageGaps, toReportOutput, } from "@open-cr-agent/core";
2
+ import { serializeOutput } from "@open-cr-agent/core/internal";
2
3
  import { forTerminal } from "./terminal.js";
3
4
  const SEVERITIES = ["critical", "warning", "suggestion"];
4
5
  const VERIFICATION = {
@@ -86,8 +87,7 @@ export function renderText(report, sessionDir) {
86
87
  }
87
88
  for (const warning of report.warnings)
88
89
  lines.push(`Warning: ${warning}`);
89
- if (sessionDir)
90
- lines.push(`Session: ${sessionDir}`);
90
+ lines.push(`Run: ${report.runId}${sessionDir ? ` (${sessionDir})` : ""}`);
91
91
  return forTerminal(`${lines.join("\n")}\n`);
92
92
  }
93
93
  function emptyMessage(report, nothingReviewed, notReviewed) {
@@ -0,0 +1,5 @@
1
+ import { type OcraPlugin } from "@open-cr-agent/core";
2
+ export type RuntimeLoaders = Readonly<Record<string, () => Promise<OcraPlugin>>>;
3
+ export declare const BUILTIN_RUNTIMES: RuntimeLoaders;
4
+ export declare function loadRuntime(runtime: string, packageName: string, load: () => Promise<OcraPlugin>): Promise<OcraPlugin>;
5
+ //# sourceMappingURL=runtimes.d.ts.map
@@ -0,0 +1,29 @@
1
+ import { OcraError } from "@open-cr-agent/core";
2
+ import { VERSION } from "../version.js";
3
+ export const BUILTIN_RUNTIMES = {
4
+ opencode: () => loadRuntime("opencode", "@open-cr-agent/runtime-opencode", async () => (await import("@open-cr-agent/runtime-opencode")).opencodeRuntimePlugin),
5
+ direct: () => loadRuntime("direct", "@open-cr-agent/runtime-direct", async () => (await import("@open-cr-agent/runtime-direct")).directRuntimePlugin),
6
+ };
7
+ export async function loadRuntime(runtime, packageName, load) {
8
+ try {
9
+ return await load();
10
+ }
11
+ catch (error) {
12
+ if (!isMissingPackage(error, packageName))
13
+ throw error;
14
+ const other = runtime === "direct" ? "opencode" : "direct";
15
+ throw new OcraError("RUNTIME_START_FAILED", `The ${runtime} runtime needs ${packageName}, which is not installed ` +
16
+ "(an optional dependency of @open-cr-agent/cli, left out by --omit=optional). " +
17
+ `Install it next to ocra with npm install ${packageName}@${VERSION} (-g for a global ocra), ` +
18
+ `or set "runtime": "${other}" in the configuration.`, { cause: error });
19
+ }
20
+ }
21
+ // Node reports a bare specifier it cannot resolve as ERR_MODULE_NOT_FOUND,
22
+ // naming the package; a module missing inside the package names another.
23
+ function isMissingPackage(error, packageName) {
24
+ if (!(error instanceof Error))
25
+ return false;
26
+ const code = error.code;
27
+ return code === "ERR_MODULE_NOT_FOUND" && error.message.includes(`'${packageName}'`);
28
+ }
29
+ //# sourceMappingURL=runtimes.js.map
@@ -0,0 +1,4 @@
1
+ import { type SarifLog } from "@open-cr-agent/core";
2
+ export declare const MAX_SARIF_BYTES: number;
3
+ export declare function loadSarifLogs(paths: readonly string[], cwd: string): Promise<SarifLog[]>;
4
+ //# sourceMappingURL=sarif-input.d.ts.map
@@ -0,0 +1,34 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { parseSarifLog } from "@open-cr-agent/core";
4
+ import { ConfigError } from "./config.js";
5
+ // A log is untrusted input like everything else that reaches the review; it
6
+ // is bounded before it is parsed.
7
+ export const MAX_SARIF_BYTES = 20 * 1024 * 1024;
8
+ export async function loadSarifLogs(paths, cwd) {
9
+ const logs = [];
10
+ for (const path of paths) {
11
+ const file = resolve(cwd, path);
12
+ let text;
13
+ try {
14
+ const { size } = await stat(file);
15
+ if (size > MAX_SARIF_BYTES) {
16
+ throw new ConfigError(`--import-sarif ${path}: larger than ${MAX_SARIF_BYTES} bytes`);
17
+ }
18
+ text = await readFile(file, "utf8");
19
+ }
20
+ catch (error) {
21
+ if (error instanceof ConfigError)
22
+ throw error;
23
+ throw new ConfigError(`--import-sarif ${path}: ${error.message}`);
24
+ }
25
+ try {
26
+ logs.push(parseSarifLog(text));
27
+ }
28
+ catch (error) {
29
+ throw new ConfigError(`--import-sarif ${path}: ${error.message}`);
30
+ }
31
+ }
32
+ return logs;
33
+ }
34
+ //# sourceMappingURL=sarif-input.js.map
@@ -1,4 +1,5 @@
1
- import { coverageGaps, serializeOutput, } from "@open-cr-agent/core";
1
+ import { coverageGaps, } from "@open-cr-agent/core";
2
+ import { serializeOutput } from "@open-cr-agent/core/internal";
2
3
  // `--format sarif`: the review as a SARIF 2.1.0 log, for code scanning and
3
4
  // security dashboards. One rule per reviewer category, one result per
4
5
  // finding. Findings an earlier review reported in files this run did not
@@ -26,6 +27,7 @@ export function renderSarif(report, version) {
26
27
  version: "2.1.0",
27
28
  runs: [
28
29
  {
30
+ automationDetails: { id: `ocra/${report.runId}` },
29
31
  tool: {
30
32
  driver: {
31
33
  name: "ocra",
@@ -36,7 +38,9 @@ export function renderSarif(report, version) {
36
38
  id,
37
39
  name: id,
38
40
  shortDescription: {
39
- text: id === CARRIED ? CARRIED_DESCRIPTION : `Findings of ocra's ${id} reviewer`,
41
+ text: id === CARRIED
42
+ ? CARRIED_DESCRIPTION
43
+ : `Findings of the ${id} reviewer or analyzer`,
40
44
  },
41
45
  properties: { tags: [id] },
42
46
  })),
@@ -100,6 +104,8 @@ function fromFinding(f, index) {
100
104
  verification: f.verification ?? "unchecked",
101
105
  status: f.status === "unfixed" ? "unfixed" : "new",
102
106
  ...(f.lowConfidence ? { lowConfidence: true } : {}),
107
+ task: f.provenance.task,
108
+ ...(f.provenance.model === undefined ? {} : { model: f.provenance.model }),
103
109
  },
104
110
  };
105
111
  }
@@ -1,4 +1,4 @@
1
- import type { OcraPlugin, PluginRegistry, VcsAdapter } from "@open-cr-agent/core";
1
+ import { type OcraPlugin, type PluginRegistry, type VcsAdapter } from "@open-cr-agent/core";
2
2
  import type { MergeRequestTarget, PullRequestTarget, ReviewArgs } from "./args.js";
3
3
  import { type CliConfig } from "./config.js";
4
4
  export interface ReviewTarget {
@@ -11,7 +11,7 @@ export interface ReviewTarget {
11
11
  }
12
12
  type Env = Readonly<Record<string, string | undefined>>;
13
13
  export declare function localTarget(args: ReviewArgs, cwd: string, root: string, env: Env, warn: (message: string) => void, fetchImpl?: typeof fetch): Promise<ReviewTarget>;
14
- export declare function pullRequestTarget(pr: PullRequestTarget, cwd: string, root: string, env: Env, warn: (message: string) => void, fetchImpl?: typeof fetch, ignoreRepoConfig?: boolean): Promise<ReviewTarget>;
15
- export declare function mergeRequestTarget(mr: MergeRequestTarget, cwd: string, root: string, env: Env, warn: (message: string) => void, fetchImpl?: typeof fetch, ignoreRepoConfig?: boolean): Promise<ReviewTarget>;
14
+ export declare function pullRequestTarget(pr: PullRequestTarget, cwd: string, root: string, env: Env, warn: (message: string) => void, fetchImpl?: typeof fetch, ignoreRepoConfig?: boolean, configFile?: string): Promise<ReviewTarget>;
15
+ export declare function mergeRequestTarget(mr: MergeRequestTarget, cwd: string, root: string, env: Env, warn: (message: string) => void, fetchImpl?: typeof fetch, ignoreRepoConfig?: boolean, configFile?: string): Promise<ReviewTarget>;
16
16
  export {};
17
17
  //# sourceMappingURL=target.d.ts.map
@@ -1,17 +1,21 @@
1
- import { DEFAULT_BOT_LOGIN, GitHubApi } from "@open-cr-agent/vcs-github";
2
- import { GitLabApi } from "@open-cr-agent/vcs-gitlab";
3
- import { ensureCommits, filesChangedSince, LocalGitAdapter } from "@open-cr-agent/vcs-local";
1
+ import { dirname } from "node:path";
2
+ import { OcraError, } from "@open-cr-agent/core";
3
+ import { DEFAULT_BOT_LOGIN, GitHubApi } from "@open-cr-agent/vcs-github/internal";
4
+ import { GitLabApi } from "@open-cr-agent/vcs-gitlab/internal";
5
+ import { ensureCommits, filesChangedSince, LocalGitAdapter, } from "@open-cr-agent/vcs-local/internal";
4
6
  import { ConfigError, loadConfig } from "./config.js";
5
7
  import { loadExternalPlugins } from "./plugins.js";
6
8
  export async function localTarget(args, cwd, root, env, warn, fetchImpl) {
7
9
  const config = await loadConfig(root, env, {
8
10
  repository: !args.ignoreRepoConfig,
11
+ ...(args.configFile ? { file: args.configFile } : {}),
9
12
  warn,
10
13
  ...(fetchImpl ? { fetch: fetchImpl } : {}),
11
14
  });
12
15
  return {
13
16
  config,
14
- plugins: await loadExternalPlugins(config.plugins, root),
17
+ // Plugins named by the user's own file are resolved from where it is.
18
+ plugins: await loadExternalPlugins(config.plugins, args.configFile ? dirname(args.configFile) : root),
15
19
  createVcs: (registry) => registry.createVcs("local", { cwd, target: args.target }),
16
20
  ...(args.ignoreRepoConfig ? { readTrusted: untrustedTreeReader(args, cwd) } : {}),
17
21
  publish: false,
@@ -29,10 +33,11 @@ function untrustedTreeReader(args, cwd) {
29
33
  }
30
34
  // The pull request's own files are untrusted: configuration, guidelines and
31
35
  // rules come from its base commit, and repository plugins never load.
32
- export async function pullRequestTarget(pr, cwd, root, env, warn, fetchImpl, ignoreRepoConfig = false) {
36
+ export async function pullRequestTarget(pr, cwd, root, env, warn, fetchImpl, ignoreRepoConfig = false, configFile) {
33
37
  const token = env.GITHUB_TOKEN ?? env.GH_TOKEN;
34
- if (!token)
35
- throw new ConfigError("--pr needs a GitHub token in GITHUB_TOKEN or GH_TOKEN");
38
+ if (!token) {
39
+ throw new OcraError("CONFIG_CREDENTIALS_MISSING", "--pr needs a GitHub token in GITHUB_TOKEN or GH_TOKEN");
40
+ }
36
41
  const [owner = "", repo = ""] = (pr.repo ?? (await repositoryName(root, env))).split("/");
37
42
  const apiOptions = { token, ...(env.GITHUB_API_URL ? { baseUrl: env.GITHUB_API_URL } : {}) };
38
43
  const api = new GitHubApi({ owner, repo }, fetchImpl ? { ...apiOptions, fetch: fetchImpl } : apiOptions);
@@ -43,11 +48,12 @@ export async function pullRequestTarget(pr, cwd, root, env, warn, fetchImpl, ign
43
48
  const config = await loadConfig(root, env, {
44
49
  repository: !ignoreRepoConfig,
45
50
  read: readTrusted,
51
+ ...(configFile ? { file: configFile } : {}),
46
52
  warn,
47
53
  ...(fetchImpl ? { fetch: fetchImpl } : {}),
48
54
  });
49
55
  if (config.plugins.length > 0) {
50
- warn("plugins in .ocra/config.json are not loaded for pull requests");
56
+ warn("plugins in the configuration are not loaded for pull requests");
51
57
  }
52
58
  const code = new LocalGitAdapter({
53
59
  cwd,
@@ -81,10 +87,10 @@ const GITLAB_API = "https://gitlab.com/api/v4";
81
87
  // target branch the merge request was based on, and repository plugins never
82
88
  // load. GitLab CI sets CI_API_V4_URL and CI_PROJECT_ID; GITLAB_TOKEN must be
83
89
  // a token that may write notes, which CI_JOB_TOKEN may not.
84
- export async function mergeRequestTarget(mr, cwd, root, env, warn, fetchImpl, ignoreRepoConfig = false) {
90
+ export async function mergeRequestTarget(mr, cwd, root, env, warn, fetchImpl, ignoreRepoConfig = false, configFile) {
85
91
  const token = env.GITLAB_TOKEN;
86
92
  if (!token) {
87
- throw new ConfigError("--mr needs a GitLab token in GITLAB_TOKEN: a project access token with the api scope and the Developer role (CI_JOB_TOKEN cannot post comments)");
93
+ throw new OcraError("CONFIG_CREDENTIALS_MISSING", "--mr needs a GitLab token in GITLAB_TOKEN: a project access token with the api scope and the Developer role (CI_JOB_TOKEN cannot post comments)");
88
94
  }
89
95
  const origin = await originUrl(root);
90
96
  const project = mr.project ?? env.CI_PROJECT_ID ?? gitlabProject(origin);
@@ -96,19 +102,21 @@ export async function mergeRequestTarget(mr, cwd, root, env, warn, fetchImpl, ig
96
102
  });
97
103
  const merge = await api.getMergeRequest(mr.iid);
98
104
  const refs = merge.diff_refs;
99
- if (!refs)
100
- throw new ConfigError(`merge request !${mr.iid} has no diff yet; try again shortly`);
105
+ if (!refs) {
106
+ throw new OcraError("VCS_NOT_READY", `merge request !${mr.iid} has no diff yet; try again shortly`);
107
+ }
101
108
  await ensureCommits(root, [refs.start_sha, refs.head_sha], [`refs/merge-requests/${mr.iid}/head`]);
102
109
  const base = new LocalGitAdapter({ cwd, target: { mode: "commit", commit: refs.start_sha } });
103
110
  const readTrusted = (path) => base.readFile(path);
104
111
  const config = await loadConfig(root, env, {
105
112
  repository: !ignoreRepoConfig,
106
113
  read: readTrusted,
114
+ ...(configFile ? { file: configFile } : {}),
107
115
  warn,
108
116
  ...(fetchImpl ? { fetch: fetchImpl } : {}),
109
117
  });
110
118
  if (config.plugins.length > 0) {
111
- warn("plugins in .ocra/config.json are not loaded for merge requests");
119
+ warn("plugins in the configuration are not loaded for merge requests");
112
120
  }
113
121
  const code = new LocalGitAdapter({
114
122
  cwd,
@@ -1,4 +1,4 @@
1
- import { isUnsafeCodePoint } from "@open-cr-agent/core";
1
+ import { isUnsafeCodePoint } from "@open-cr-agent/core/internal";
2
2
  // Findings, commit messages and errors can carry text a diff planted; none of
3
3
  // it may drive the user's terminal. C0 and C1 controls (ANSI and OSC escapes
4
4
  // start with ESC or a C1 code) except tab and newline, carriage returns, and
package/dist/run.js CHANGED
@@ -1,9 +1,12 @@
1
1
  import { writeFile } from "node:fs/promises";
2
2
  import { parseArgs } from "node:util";
3
- import { errorMessage } from "@open-cr-agent/core";
3
+ import { isOcraError } from "@open-cr-agent/core";
4
+ import { errorMessage } from "@open-cr-agent/core/internal";
4
5
  import { memoryCommand } from "./memory.js";
6
+ import { metricsCommand } from "./metrics.js";
5
7
  import { parseReviewArgs, REVIEW_USAGE, UsageError } from "./review/args.js";
6
8
  import { BUILTIN_PLUGINS, EXIT, reviewCommand } from "./review/command.js";
9
+ import { BUILTIN_RUNTIMES } from "./review/runtimes.js";
7
10
  import { forTerminal } from "./review/terminal.js";
8
11
  import { VERSION } from "./version.js";
9
12
  const USAGE = `Usage: ocra <command> [options]
@@ -11,6 +14,7 @@ const USAGE = `Usage: ocra <command> [options]
11
14
  Commands:
12
15
  review Review code changes (run "ocra review --help" for options)
13
16
  memory Remember findings the team accepts (run "ocra memory --help")
17
+ metrics Counts over past reviews: runs, cost, findings, per reviewer (run "ocra metrics --help")
14
18
 
15
19
  Options:
16
20
  -h, --help Show help
@@ -21,6 +25,7 @@ export function defaultDeps() {
21
25
  cwd: process.cwd(),
22
26
  env: process.env,
23
27
  builtinPlugins: BUILTIN_PLUGINS,
28
+ runtimes: BUILTIN_RUNTIMES,
24
29
  writeFile: (path, content) => writeFile(path, content, "utf8"),
25
30
  now: Date.now,
26
31
  heartbeatMs: 30_000,
@@ -45,12 +50,14 @@ export async function run(argv, out, err, deps = defaultDeps()) {
45
50
  const [command, ...rest] = argv;
46
51
  if (command === "review")
47
52
  return review(rest, out, err, deps);
48
- if (command === "memory") {
53
+ if (command === "memory" || command === "metrics") {
49
54
  try {
50
- return await memoryCommand(rest, out, deps.cwd);
55
+ return command === "memory"
56
+ ? await memoryCommand(rest, out, deps.cwd)
57
+ : await metricsCommand(rest, out, deps.cwd);
51
58
  }
52
59
  catch (error) {
53
- err.write(`ocra: ${forTerminal(errorMessage(error))}\n`);
60
+ err.write(failure(error));
54
61
  return EXIT.error;
55
62
  }
56
63
  }
@@ -91,9 +98,14 @@ async function review(argv, out, err, deps) {
91
98
  err.write(`${error.message}\n\n${REVIEW_USAGE}`);
92
99
  }
93
100
  else {
94
- err.write(`ocra: ${forTerminal(errorMessage(error))}\n`);
101
+ err.write(failure(error));
95
102
  }
96
103
  return EXIT.error;
97
104
  }
98
105
  }
106
+ // The code is what a script may match on; the message is for people.
107
+ function failure(error) {
108
+ const code = isOcraError(error) ? ` [${error.code}]` : "";
109
+ return `ocra${code}: ${forTerminal(errorMessage(error))}\n`;
110
+ }
99
111
  //# sourceMappingURL=run.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-cr-agent/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "ocra: open-source multi-agent code review for local changes and GitHub pull requests",
5
5
  "keywords": [
6
6
  "code-review",
@@ -23,9 +23,11 @@
23
23
  "type": "module",
24
24
  "exports": {
25
25
  ".": {
26
+ "@open-cr-agent/source": "./src/index.ts",
26
27
  "types": "./dist/index.d.ts",
27
28
  "default": "./dist/index.js"
28
- }
29
+ },
30
+ "./package.json": "./package.json"
29
31
  },
30
32
  "bin": {
31
33
  "ocra": "./dist/main.js"
@@ -35,13 +37,16 @@
35
37
  "!dist/**/*.map"
36
38
  ],
37
39
  "dependencies": {
38
- "@open-cr-agent/core": "0.2.0",
39
- "@open-cr-agent/runtime-opencode": "0.2.0",
40
- "@open-cr-agent/vcs-github": "0.2.0",
41
- "@open-cr-agent/vcs-gitlab": "0.2.0",
42
- "@open-cr-agent/vcs-local": "0.2.0",
40
+ "@open-cr-agent/core": "0.3.0",
41
+ "@open-cr-agent/runtime-direct": "0.3.0",
42
+ "@open-cr-agent/vcs-github": "0.3.0",
43
+ "@open-cr-agent/vcs-gitlab": "0.3.0",
44
+ "@open-cr-agent/vcs-local": "0.3.0",
43
45
  "zod": "^4.6.5"
44
46
  },
47
+ "optionalDependencies": {
48
+ "@open-cr-agent/runtime-opencode": "0.3.0"
49
+ },
45
50
  "publishConfig": {
46
51
  "access": "public"
47
52
  }