@open-cr-agent/cli 0.4.0 → 0.5.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/account-salt.d.ts +7 -0
- package/dist/account-salt.js +43 -0
- package/dist/cloud.d.ts +2 -0
- package/dist/cloud.js +11 -1
- package/dist/plugin-store.d.ts +20 -0
- package/dist/plugin-store.js +84 -0
- package/dist/plugins-command.d.ts +15 -0
- package/dist/plugins-command.js +183 -0
- package/dist/review/account-plugins.d.ts +23 -0
- package/dist/review/account-plugins.js +54 -0
- package/dist/review/account-settings.d.ts +100 -0
- package/dist/review/account-settings.js +106 -0
- package/dist/review/cloud-findings.d.ts +31 -0
- package/dist/review/cloud-findings.js +80 -0
- package/dist/review/cloud-memory.d.ts +6 -0
- package/dist/review/cloud-memory.js +58 -0
- package/dist/review/cloud-providers.d.ts +2 -2
- package/dist/review/cloud-providers.js +3 -2
- package/dist/review/cloud-review.d.ts +19 -0
- package/dist/review/cloud-review.js +51 -0
- package/dist/review/cloud-settings.d.ts +16 -0
- package/dist/review/cloud-settings.js +118 -0
- package/dist/review/cloud-upload.d.ts +26 -6
- package/dist/review/cloud-upload.js +82 -37
- package/dist/review/command.d.ts +2 -0
- package/dist/review/command.js +81 -28
- package/dist/review/config.d.ts +17 -6
- package/dist/review/config.js +31 -7
- package/dist/review/plan-render.js +1 -1
- package/dist/review/plugins.d.ts +10 -0
- package/dist/review/plugins.js +46 -1
- package/dist/review/provenance.js +11 -1
- package/dist/review/render.js +7 -2
- package/dist/review/settings-sources.d.ts +11 -0
- package/dist/review/settings-sources.js +58 -0
- package/dist/review/target.d.ts +1 -0
- package/dist/review/target.js +3 -0
- package/dist/run.js +19 -0
- package/package.json +7 -7
|
@@ -3,14 +3,17 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
3
3
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
|
-
import { coverageGaps } from "@open-cr-agent/core";
|
|
6
|
+
import { coverageGaps, } from "@open-cr-agent/core";
|
|
7
|
+
import { verificationSchema } from "@open-cr-agent/core/internal";
|
|
7
8
|
import { cloudSession, readCredentials } from "../cloud.js";
|
|
8
9
|
import { VERSION } from "../version.js";
|
|
9
10
|
// After a review, a signed-in CLI sends ocra Cloud its counts (ADR-0024):
|
|
10
11
|
// the verdict, how many findings of each severity, files and tasks, tokens
|
|
11
|
-
// and time.
|
|
12
|
-
//
|
|
13
|
-
// can group reviews of one repository without
|
|
12
|
+
// and time. A path, a title, a finding's text or code go only when the
|
|
13
|
+
// account shares findings (ADR-0028, cloud-findings.ts). The repository is
|
|
14
|
+
// a salted hash, so the server can group reviews of one repository without
|
|
15
|
+
// learning which it is: the salt stays on this machine, or is the account's
|
|
16
|
+
// while it shares findings, so the hash matches on all its machines.
|
|
14
17
|
const run = promisify(execFile);
|
|
15
18
|
export function uploadOf(report, source, repoHash, durationMs) {
|
|
16
19
|
const severity = (s) => report.findings.filter((f) => f.severity === s).length;
|
|
@@ -42,6 +45,61 @@ export function uploadOf(report, source, repoHash, durationMs) {
|
|
|
42
45
|
},
|
|
43
46
|
durationMs: Math.round(durationMs),
|
|
44
47
|
ocraVersion: VERSION,
|
|
48
|
+
...perReviewer(report),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// Counted as `ocra metrics` counts one report: a failed or timed-out task is
|
|
52
|
+
// failed, a finding without a verification is unchecked, a dismissal outranks
|
|
53
|
+
// a fix, and a fixed or dismissed finding is attributed to the reviewer of a
|
|
54
|
+
// finding with its fingerprint in this report, or to none.
|
|
55
|
+
function perReviewer(report) {
|
|
56
|
+
const reviewers = new Map();
|
|
57
|
+
const of = (id) => {
|
|
58
|
+
let r = reviewers.get(id);
|
|
59
|
+
if (!r) {
|
|
60
|
+
r = {
|
|
61
|
+
tasks: 0,
|
|
62
|
+
failedTasks: 0,
|
|
63
|
+
findings: { critical: 0, warning: 0, suggestion: 0 },
|
|
64
|
+
costUsd: 0,
|
|
65
|
+
fixed: 0,
|
|
66
|
+
dismissed: 0,
|
|
67
|
+
};
|
|
68
|
+
reviewers.set(id, r);
|
|
69
|
+
}
|
|
70
|
+
return r;
|
|
71
|
+
};
|
|
72
|
+
for (const task of report.tasks) {
|
|
73
|
+
const r = of(task.reviewer);
|
|
74
|
+
r.tasks += 1;
|
|
75
|
+
if (task.status === "failed" || task.status === "timed_out")
|
|
76
|
+
r.failedTasks += 1;
|
|
77
|
+
r.costUsd += task.usage.costUsd;
|
|
78
|
+
}
|
|
79
|
+
const verification = Object.fromEntries(verificationSchema.options.map((v) => [v, 0]));
|
|
80
|
+
const reviewerOf = new Map();
|
|
81
|
+
for (const finding of report.findings) {
|
|
82
|
+
of(finding.reviewer).findings[finding.severity] += 1;
|
|
83
|
+
verification[finding.verification ?? "unchecked"] += 1;
|
|
84
|
+
if (!reviewerOf.has(finding.fingerprint))
|
|
85
|
+
reviewerOf.set(finding.fingerprint, finding.reviewer);
|
|
86
|
+
}
|
|
87
|
+
const dismissed = new Set((report.rereview?.dismissed ?? []).map((f) => f.fingerprint));
|
|
88
|
+
const fixed = new Set((report.rereview?.fixed ?? []).map((f) => f.fingerprint).filter((fp) => !dismissed.has(fp)));
|
|
89
|
+
for (const fp of fixed) {
|
|
90
|
+
const reviewer = reviewerOf.get(fp);
|
|
91
|
+
if (reviewer)
|
|
92
|
+
of(reviewer).fixed += 1;
|
|
93
|
+
}
|
|
94
|
+
for (const fp of dismissed) {
|
|
95
|
+
const reviewer = reviewerOf.get(fp);
|
|
96
|
+
if (reviewer)
|
|
97
|
+
of(reviewer).dismissed += 1;
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
reviewers: Object.fromEntries([...reviewers].sort(([a], [b]) => a.localeCompare(b))),
|
|
101
|
+
verification,
|
|
102
|
+
outcomes: { fixed: fixed.size, dismissed: dismissed.size },
|
|
45
103
|
};
|
|
46
104
|
}
|
|
47
105
|
/** The repository's origin without credentials, or its root path when it has none. */
|
|
@@ -60,8 +118,8 @@ async function repositoryId(root) {
|
|
|
60
118
|
return root;
|
|
61
119
|
}
|
|
62
120
|
}
|
|
63
|
-
/**
|
|
64
|
-
async function
|
|
121
|
+
/** This machine's random salt, kept beside the credentials, made on first use. */
|
|
122
|
+
async function machineSalt(credentialsPath) {
|
|
65
123
|
const path = join(dirname(credentialsPath), "upload-salt");
|
|
66
124
|
try {
|
|
67
125
|
const existing = (await readFile(path, "utf8")).trim();
|
|
@@ -74,21 +132,27 @@ async function salt(credentialsPath) {
|
|
|
74
132
|
await writeFile(path, `${fresh}\n`, { mode: 0o600 });
|
|
75
133
|
return fresh;
|
|
76
134
|
}
|
|
77
|
-
|
|
135
|
+
/** The repository's hash, salted with the account's salt when given, else this machine's. */
|
|
136
|
+
export async function repoHash(root, credentialsPath, accountSalt) {
|
|
137
|
+
const salt = accountSalt ?? (await machineSalt(credentialsPath));
|
|
78
138
|
return createHash("sha256")
|
|
79
|
-
.update(`${
|
|
139
|
+
.update(`${salt}:${await repositoryId(root)}`)
|
|
80
140
|
.digest("hex");
|
|
81
141
|
}
|
|
82
142
|
/** Whether this run talks to ocra Cloud at all: signed in, not turned off. */
|
|
83
143
|
export async function cloudEnabled(deps) {
|
|
84
144
|
return (deps.env.OCRA_CLOUD !== "off" && (await readCredentials(deps.credentialsPath)) !== undefined);
|
|
85
145
|
}
|
|
86
|
-
/**
|
|
87
|
-
|
|
146
|
+
/**
|
|
147
|
+
* Sends the counts, and the findings when given; answers how many findings
|
|
148
|
+
* the server kept, or undefined when it took nothing. A failure is a
|
|
149
|
+
* warning, never a failed review.
|
|
150
|
+
*/
|
|
151
|
+
export async function uploadReview(upload, deps, warn, findings) {
|
|
88
152
|
try {
|
|
89
153
|
const session = await cloudSession(deps);
|
|
90
154
|
if (!session)
|
|
91
|
-
return
|
|
155
|
+
return undefined;
|
|
92
156
|
const res = await deps.fetch(`${session.server}/api/reviews`, {
|
|
93
157
|
method: "POST",
|
|
94
158
|
headers: {
|
|
@@ -96,40 +160,21 @@ export async function uploadReview(upload, deps, warn) {
|
|
|
96
160
|
authorization: `Bearer ${session.access_token}`,
|
|
97
161
|
"user-agent": `ocra/${VERSION}`,
|
|
98
162
|
},
|
|
99
|
-
|
|
163
|
+
// The counts stay in `findings`; the shared list goes in `findingList`.
|
|
164
|
+
body: JSON.stringify(findings ? { ...upload, findingList: findings } : upload),
|
|
100
165
|
signal: AbortSignal.timeout(15_000),
|
|
101
166
|
});
|
|
102
167
|
if (!res.ok) {
|
|
103
168
|
warn(`ocra Cloud did not take the review's counts (HTTP ${res.status})`);
|
|
104
|
-
return
|
|
169
|
+
return undefined;
|
|
105
170
|
}
|
|
106
|
-
|
|
171
|
+
const answer = (await res.json().catch(() => ({})));
|
|
172
|
+
const kept = answer.findings;
|
|
173
|
+
return { findings: typeof kept === "number" && Number.isInteger(kept) && kept > 0 ? kept : 0 };
|
|
107
174
|
}
|
|
108
175
|
catch (error) {
|
|
109
176
|
warn(`could not send the review's counts to ocra Cloud: ${error instanceof Error ? error.name : "error"}`);
|
|
110
|
-
return
|
|
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
|
-
}
|
|
177
|
+
return undefined;
|
|
132
178
|
}
|
|
133
|
-
return chains;
|
|
134
179
|
}
|
|
135
180
|
//# sourceMappingURL=cloud-upload.js.map
|
package/dist/review/command.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type OcraPlugin } from "@open-cr-agent/core";
|
|
2
2
|
import type { CloudDeps } from "../cloud.js";
|
|
3
|
+
import type { NpmRunner } from "../plugins-command.js";
|
|
3
4
|
import type { ReviewArgs } from "./args.js";
|
|
4
5
|
import { type Output } from "./progress.js";
|
|
5
6
|
import type { RuntimeLoaders } from "./runtimes.js";
|
|
@@ -22,6 +23,7 @@ export interface ReviewDeps {
|
|
|
22
23
|
heartbeatMs: number;
|
|
23
24
|
fetch?: typeof fetch;
|
|
24
25
|
cloud?: CloudDeps;
|
|
26
|
+
npm?: NpmRunner;
|
|
25
27
|
onInterrupt?(handler: () => void): () => void;
|
|
26
28
|
}
|
|
27
29
|
export declare function reviewCommand(args: ReviewArgs, io: {
|
package/dist/review/command.js
CHANGED
|
@@ -5,16 +5,21 @@ import { githubPlugin } from "@open-cr-agent/vcs-github";
|
|
|
5
5
|
import { gitlabPlugin } from "@open-cr-agent/vcs-gitlab";
|
|
6
6
|
import { localGitPlugin } from "@open-cr-agent/vcs-local";
|
|
7
7
|
import { findRepositoryRoot } from "@open-cr-agent/vcs-local/internal";
|
|
8
|
+
import { pluginsDir } from "../plugin-store.js";
|
|
8
9
|
import { VERSION } from "../version.js";
|
|
10
|
+
import { accountPlugins } from "./account-plugins.js";
|
|
9
11
|
import { withCloudProviders } from "./cloud-providers.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
+
import { prepareCloudReview, sendToCloud } from "./cloud-review.js";
|
|
13
|
+
import { fetchAccountSettings, layerAccountSettings } from "./cloud-settings.js";
|
|
14
|
+
import { cloudEnabled } from "./cloud-upload.js";
|
|
15
|
+
import { agentChains, ConfigError } from "./config.js";
|
|
12
16
|
import { renderPlan } from "./plan-render.js";
|
|
13
17
|
import { ProgressPrinter } from "./progress.js";
|
|
14
18
|
import { configHash, requestedSampling } from "./provenance.js";
|
|
15
19
|
import { renderJson, renderText, safeJson } from "./render.js";
|
|
16
20
|
import { renderSarif } from "./sarif.js";
|
|
17
21
|
import { loadSarifLogs } from "./sarif-input.js";
|
|
22
|
+
import { effectiveSettings, renderSettings } from "./settings-sources.js";
|
|
18
23
|
import { localTarget, mergeRequestTarget, pullRequestTarget } from "./target.js";
|
|
19
24
|
import { forTerminal } from "./terminal.js";
|
|
20
25
|
export const SESSIONS_DIR = ".ocra/sessions";
|
|
@@ -41,21 +46,55 @@ export async function reviewCommand(args, io, deps) {
|
|
|
41
46
|
: args.mergeRequest
|
|
42
47
|
? await mergeRequestTarget(args.mergeRequest, deps.cwd, root, deps.env, warn, deps.fetch, ignoreRepoConfig, configFile)
|
|
43
48
|
: await localTarget(localArgs, deps.cwd, root, deps.env, warn, deps.fetch);
|
|
44
|
-
|
|
49
|
+
// The account's settings fill what the repository leaves out (ADR-0027);
|
|
50
|
+
// unreachable, they cost a warning, so a plan still works offline.
|
|
51
|
+
const cloudDeps = deps.cloud;
|
|
52
|
+
const signedIn = cloudDeps !== undefined && (await cloudEnabled(cloudDeps));
|
|
53
|
+
let config = target.config;
|
|
54
|
+
let filled = [];
|
|
55
|
+
let fromAccount = { plugins: [], pluginSettings: {} };
|
|
56
|
+
if (cloudDeps && signedIn) {
|
|
57
|
+
const account = await fetchAccountSettings(cloudDeps, warn);
|
|
58
|
+
if (account?.settings) {
|
|
59
|
+
({ config, filled } = layerAccountSettings(config, account.settings));
|
|
60
|
+
// The account's default for --ultra; there is no --no-ultra to refuse it.
|
|
61
|
+
if (account.settings.ultra === true && !args.ultra)
|
|
62
|
+
filled.push("ultra");
|
|
63
|
+
if (filled.length > 0) {
|
|
64
|
+
io.err.write(`[ocra] From your ocra Cloud settings: ${filled.join(", ")}\n`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (account)
|
|
68
|
+
fromAccount = account.plugins;
|
|
69
|
+
}
|
|
70
|
+
const ultra = args.ultra === true || filled.includes("ultra");
|
|
45
71
|
const session = { dir: join(root, SESSIONS_DIR), id: newRunId() };
|
|
46
72
|
// A plan calls no model, writes no session log and imports no runtime: it
|
|
47
73
|
// works without the optional runtime-opencode.
|
|
48
74
|
const builtins = args.plan
|
|
49
75
|
? deps.builtinPlugins.filter((p) => p.name !== sessionJsonlPlugin.name)
|
|
50
76
|
: [...deps.builtinPlugins, ...(await builtinRuntime(deps.runtimes, config.runtime))];
|
|
51
|
-
const
|
|
77
|
+
const account = await accountPlugins(fromAccount, {
|
|
78
|
+
allowed: target.accountPlugins,
|
|
79
|
+
configured: config.plugins,
|
|
80
|
+
taken: [...builtins, ...target.plugins],
|
|
81
|
+
dir: pluginsDir(deps.env),
|
|
82
|
+
warn,
|
|
83
|
+
});
|
|
84
|
+
// The repository's own settings win; a repository plugin's come from it alone.
|
|
85
|
+
const pluginSettings = { ...account.settings, ...config.pluginSettings };
|
|
86
|
+
const registry = await startPlugins([...builtins, ...target.plugins, ...account.plugins], {
|
|
52
87
|
settings: args.plan
|
|
53
|
-
?
|
|
54
|
-
: { ...
|
|
88
|
+
? pluginSettings
|
|
89
|
+
: { ...pluginSettings, [sessionJsonlPlugin.name]: session },
|
|
55
90
|
env: deps.env,
|
|
56
91
|
warn,
|
|
57
92
|
});
|
|
58
93
|
const vcs = target.createVcs(registry);
|
|
94
|
+
const rules = [
|
|
95
|
+
...registry.rules.map((rule) => ({ ...rule, source: "plugin" })),
|
|
96
|
+
...config.rules,
|
|
97
|
+
];
|
|
59
98
|
const overrides = reviewerOverrides(config, args, registry.reviewers.map((r) => r.id));
|
|
60
99
|
if (args.plan) {
|
|
61
100
|
const preview = await previewReview({
|
|
@@ -64,13 +103,17 @@ export async function reviewCommand(args, io, deps) {
|
|
|
64
103
|
reviewerOverrides: overrides,
|
|
65
104
|
effort: config.effort,
|
|
66
105
|
roles: config.roles,
|
|
67
|
-
|
|
106
|
+
models: config.models,
|
|
107
|
+
rules,
|
|
68
108
|
selection: { ...defaultSelectionPolicy, include: config.include, exclude: config.exclude },
|
|
69
109
|
...(target.readTrusted ? { readTrusted: target.readTrusted } : {}),
|
|
70
|
-
...(
|
|
110
|
+
...(ultra ? { ultra: true } : {}),
|
|
71
111
|
...(config.maxTasks !== undefined ? { maxTasks: config.maxTasks } : {}),
|
|
72
112
|
});
|
|
73
|
-
const
|
|
113
|
+
const settings = effectiveSettings(target.config, config, filled);
|
|
114
|
+
const rendered = args.format === "json"
|
|
115
|
+
? `${safeJson({ ...toPlanOutput(preview), settings, ...accountOf(config) })}\n`
|
|
116
|
+
: renderPlan(preview) + renderSettings(settings, config.accountSettings);
|
|
74
117
|
if (args.output === undefined)
|
|
75
118
|
io.out.write(rendered);
|
|
76
119
|
else
|
|
@@ -78,19 +121,15 @@ export async function reviewCommand(args, io, deps) {
|
|
|
78
121
|
return EXIT.ok;
|
|
79
122
|
}
|
|
80
123
|
const sarif = await loadSarifLogs(args.importSarif ?? [], deps.cwd);
|
|
124
|
+
// The repository's hash and the account's memory for it (ADR-0028).
|
|
125
|
+
const cloudReview = cloudDeps && signedIn ? await prepareCloudReview(root, cloudDeps, warn) : undefined;
|
|
81
126
|
const sampling = requestedSampling(config, args);
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
|
|
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);
|
|
127
|
+
const models = config.models;
|
|
128
|
+
const agentModels = agentChains({ reviewers: overrides, roles: config.roles });
|
|
129
|
+
const cloud = await withCloudProviders([...Object.values(models), ...Object.values(agentModels)], config.providers, deps.env, cloudDeps, warn);
|
|
92
130
|
const runtime = registry.createRuntime(config.runtime, {
|
|
93
131
|
models,
|
|
132
|
+
...(Object.keys(agentModels).length > 0 ? { agentModels } : {}),
|
|
94
133
|
env: cloud.env,
|
|
95
134
|
providers: cloud.providers,
|
|
96
135
|
...(Object.keys(sampling).length > 0 ? { sampling } : {}),
|
|
@@ -108,17 +147,24 @@ export async function reviewCommand(args, io, deps) {
|
|
|
108
147
|
runId: session.id,
|
|
109
148
|
signal: interrupt.signal,
|
|
110
149
|
...runOptions(config),
|
|
150
|
+
models,
|
|
111
151
|
reviewerOverrides: overrides,
|
|
112
|
-
provenance: {
|
|
152
|
+
provenance: {
|
|
153
|
+
ocraVersion: VERSION,
|
|
154
|
+
configHash: configHash(config, ultra ? { ...args, ultra: true } : args),
|
|
155
|
+
sampling,
|
|
156
|
+
...accountOf(config),
|
|
157
|
+
},
|
|
113
158
|
...(args.maxCostUsd !== undefined ? { maxCostUsd: args.maxCostUsd } : {}),
|
|
114
|
-
...(
|
|
159
|
+
...(ultra ? { ultra: true } : {}),
|
|
115
160
|
...(args.full ? { fullReview: true } : {}),
|
|
116
161
|
...(sarif.length > 0 ? { sarif } : {}),
|
|
117
162
|
...(target.readTrusted ? { readTrusted: target.readTrusted } : {}),
|
|
118
163
|
vcs,
|
|
119
164
|
runtime,
|
|
120
165
|
reviewers: registry.reviewers,
|
|
121
|
-
rules
|
|
166
|
+
rules,
|
|
167
|
+
...(cloudReview?.memory.length ? { accountMemory: cloudReview.memory } : {}),
|
|
122
168
|
onEvent: (event) => {
|
|
123
169
|
registry.emit(event);
|
|
124
170
|
progress.onEvent(event);
|
|
@@ -143,6 +189,9 @@ export async function reviewCommand(args, io, deps) {
|
|
|
143
189
|
await deps.writeFile(resolve(deps.cwd, args.output), rendered);
|
|
144
190
|
io.err.write(`[ocra] Wrote ${args.output}\n`);
|
|
145
191
|
}
|
|
192
|
+
const hidden = report.remembered.filter((e) => e.source === "account").length;
|
|
193
|
+
if (hidden > 0)
|
|
194
|
+
io.err.write(`[ocra] ${hidden} finding(s) hidden by your ocra Cloud memory\n`);
|
|
146
195
|
if (interrupt.signal.aborted)
|
|
147
196
|
return EXIT.interrupted;
|
|
148
197
|
if (target.publish) {
|
|
@@ -161,15 +210,19 @@ export async function reviewCommand(args, io, deps) {
|
|
|
161
210
|
}
|
|
162
211
|
io.err.write(`[ocra] Published the review to the ${target.publishesTo ?? "change request"}\n`);
|
|
163
212
|
}
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
io.err
|
|
169
|
-
|
|
213
|
+
if (cloudReview && cloudDeps && !args.noUpload) {
|
|
214
|
+
await sendToCloud(report, cloudReview, cloudDeps, {
|
|
215
|
+
source: args.pullRequest ? "github" : args.mergeRequest ? "gitlab" : "local",
|
|
216
|
+
durationMs: deps.now() - started,
|
|
217
|
+
err: io.err,
|
|
218
|
+
warn,
|
|
219
|
+
});
|
|
170
220
|
}
|
|
171
221
|
return exitCode(report, io.err);
|
|
172
222
|
}
|
|
223
|
+
function accountOf(config) {
|
|
224
|
+
return config.accountSettings ? { accountSettings: config.accountSettings } : {};
|
|
225
|
+
}
|
|
173
226
|
// A runtime not built in comes from a plugin of the configuration.
|
|
174
227
|
async function builtinRuntime(runtimes, name) {
|
|
175
228
|
const load = Object.hasOwn(runtimes, name) ? runtimes[name] : undefined;
|
package/dist/review/config.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { type CustomProvider, type ModelChains, OcraError, type
|
|
1
|
+
import { type CustomProvider, type ModelChains, OcraError, type ReviewerOverrides, type RoleSettings, type SourcedRule, type TierEfforts } from "@open-cr-agent/core";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
export declare const CONFIG_PATH = ".ocra/config.json";
|
|
4
|
-
declare const
|
|
4
|
+
export declare const DEFAULT_RUNTIME = "opencode";
|
|
5
|
+
export declare const configSchema: z.ZodObject<{
|
|
5
6
|
$schema: z.ZodOptional<z.ZodString>;
|
|
6
7
|
models: z.ZodDefault<z.ZodObject<{
|
|
7
8
|
top: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<string[], string | string[]>>>;
|
|
@@ -48,7 +49,7 @@ declare const configSchema: z.ZodObject<{
|
|
|
48
49
|
}, z.core.$strict>>;
|
|
49
50
|
include: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
50
51
|
exclude: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
51
|
-
runtime: z.
|
|
52
|
+
runtime: z.ZodOptional<z.ZodUnion<[z.ZodEnum<{
|
|
52
53
|
direct: "direct";
|
|
53
54
|
opencode: "opencode";
|
|
54
55
|
}>, z.ZodString]>>;
|
|
@@ -60,6 +61,7 @@ declare const configSchema: z.ZodObject<{
|
|
|
60
61
|
lite: "lite";
|
|
61
62
|
trivial: "trivial";
|
|
62
63
|
}>>;
|
|
64
|
+
models: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<string[], string | string[]>>>;
|
|
63
65
|
effort: z.ZodOptional<z.ZodEnum<{
|
|
64
66
|
high: "high";
|
|
65
67
|
low: "low";
|
|
@@ -73,6 +75,7 @@ declare const configSchema: z.ZodObject<{
|
|
|
73
75
|
judge: "judge";
|
|
74
76
|
verifier: "verifier";
|
|
75
77
|
}> & z.core.$partial, z.ZodObject<{
|
|
78
|
+
models: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<string[], string | string[]>>>;
|
|
76
79
|
effort: z.ZodOptional<z.ZodEnum<{
|
|
77
80
|
high: "high";
|
|
78
81
|
low: "low";
|
|
@@ -100,11 +103,16 @@ declare const configSchema: z.ZodObject<{
|
|
|
100
103
|
}, z.core.$strict>;
|
|
101
104
|
export declare const CONFIG_SCHEMA_ID = "https://ocra.majincheng.com/schema/config.v1.json";
|
|
102
105
|
export declare function configJsonSchema(): Record<string, unknown>;
|
|
103
|
-
export type CliConfig = Omit<z.infer<typeof configSchema>, "models" | "effort" | "providers"> & {
|
|
106
|
+
export type CliConfig = Omit<z.infer<typeof configSchema>, "models" | "effort" | "providers" | "runtime"> & {
|
|
107
|
+
runtime: string;
|
|
108
|
+
runtimeSet: boolean;
|
|
104
109
|
models: ModelChains;
|
|
105
110
|
effort: TierEfforts;
|
|
106
111
|
providers: Record<string, CustomProvider>;
|
|
107
|
-
rules:
|
|
112
|
+
rules: SourcedRule[];
|
|
113
|
+
accountSettings?: {
|
|
114
|
+
version: string | null;
|
|
115
|
+
};
|
|
108
116
|
};
|
|
109
117
|
export interface LoadOptions {
|
|
110
118
|
repository: boolean;
|
|
@@ -119,5 +127,8 @@ export declare class ConfigError extends OcraError {
|
|
|
119
127
|
});
|
|
120
128
|
}
|
|
121
129
|
export declare function loadConfig(root: string, env: Readonly<Record<string, string | undefined>>, options?: LoadOptions): Promise<CliConfig>;
|
|
122
|
-
export {
|
|
130
|
+
export declare function agentChains(config: {
|
|
131
|
+
reviewers: ReviewerOverrides;
|
|
132
|
+
roles: RoleSettings;
|
|
133
|
+
}): Record<string, readonly string[]>;
|
|
123
134
|
//# sourceMappingURL=config.d.ts.map
|
package/dist/review/config.js
CHANGED
|
@@ -51,7 +51,8 @@ const providerSchema = z
|
|
|
51
51
|
// The runtimes ocra ships; a plugin may register another name, so the
|
|
52
52
|
// schema lists these for editors and still accepts any other string.
|
|
53
53
|
const BUILTIN_RUNTIMES = ["opencode", "direct"];
|
|
54
|
-
const
|
|
54
|
+
export const DEFAULT_RUNTIME = "opencode";
|
|
55
|
+
export const configSchema = z
|
|
55
56
|
.object({
|
|
56
57
|
$schema: z.string().optional(),
|
|
57
58
|
models: z
|
|
@@ -92,20 +93,24 @@ const configSchema = z
|
|
|
92
93
|
.default({}),
|
|
93
94
|
include: z.array(z.string().min(1)).default([]),
|
|
94
95
|
exclude: z.array(z.string().min(1)).default([]),
|
|
95
|
-
|
|
96
|
+
// Unset means "opencode"; loadConfig fills it in and records whether the
|
|
97
|
+
// file chose, so account defaults (ADR-0025) apply only where it did not.
|
|
98
|
+
runtime: z.enum(BUILTIN_RUNTIMES).or(z.string().min(1)).optional(),
|
|
96
99
|
plugins: z.array(z.string().min(1)).default([]),
|
|
97
100
|
reviewers: z
|
|
98
101
|
.record(z.string(), z
|
|
99
102
|
.object({
|
|
100
103
|
enabled: z.boolean(),
|
|
101
104
|
minTier: z.enum(RISK_TIERS),
|
|
105
|
+
// The reviewer's own chain, for its review tasks and plan call.
|
|
106
|
+
models: modelChain,
|
|
102
107
|
effort,
|
|
103
108
|
})
|
|
104
109
|
.partial()
|
|
105
110
|
.strict())
|
|
106
111
|
.default({}),
|
|
107
112
|
roles: z
|
|
108
|
-
.partialRecord(z.enum(AGENT_ROLES), z.object({ effort }).partial().strict())
|
|
113
|
+
.partialRecord(z.enum(AGENT_ROLES), z.object({ models: modelChain, effort }).partial().strict())
|
|
109
114
|
.default({}),
|
|
110
115
|
pluginSettings: z.record(z.string(), z.unknown()).default({}),
|
|
111
116
|
providers: z.record(z.string().regex(/^[a-z][a-z0-9-]{0,39}$/), providerSchema).default({}),
|
|
@@ -177,15 +182,20 @@ export async function loadConfig(root, env, options = { repository: true }) {
|
|
|
177
182
|
}
|
|
178
183
|
efforts[tier] = level.data;
|
|
179
184
|
}
|
|
180
|
-
for (const model of unpriced(parsed.providers,
|
|
185
|
+
for (const model of unpriced(parsed.providers, [
|
|
186
|
+
...Object.values(models),
|
|
187
|
+
...Object.values(agentChains(parsed)),
|
|
188
|
+
])) {
|
|
181
189
|
options.warn?.(`${model} has a price of 0: reported cost and --max-cost-usd do not count its tokens`);
|
|
182
190
|
}
|
|
183
191
|
return {
|
|
184
192
|
...parsed,
|
|
193
|
+
runtime: parsed.runtime ?? DEFAULT_RUNTIME,
|
|
194
|
+
runtimeSet: parsed.runtime !== undefined,
|
|
185
195
|
models,
|
|
186
196
|
effort: efforts,
|
|
187
197
|
providers: toProviders(parsed.providers),
|
|
188
|
-
rules,
|
|
198
|
+
rules: rules.map((rule) => ({ ...rule, source: "shared" })),
|
|
189
199
|
};
|
|
190
200
|
}
|
|
191
201
|
function toProviders(declared) {
|
|
@@ -206,9 +216,23 @@ function toProviders(declared) {
|
|
|
206
216
|
},
|
|
207
217
|
]));
|
|
208
218
|
}
|
|
219
|
+
// The reviewers' and roles' own chains, by agent id (ADR-0025); a disabled
|
|
220
|
+
// reviewer's is left out, since it makes no call.
|
|
221
|
+
export function agentChains(config) {
|
|
222
|
+
const chains = {};
|
|
223
|
+
for (const [id, setting] of Object.entries(config.reviewers)) {
|
|
224
|
+
if (setting.models && setting.enabled !== false)
|
|
225
|
+
chains[id] = setting.models;
|
|
226
|
+
}
|
|
227
|
+
for (const [role, setting] of Object.entries(config.roles)) {
|
|
228
|
+
if (setting?.models)
|
|
229
|
+
chains[role] = setting.models;
|
|
230
|
+
}
|
|
231
|
+
return chains;
|
|
232
|
+
}
|
|
209
233
|
// Models of declared providers, named in a chain, priced at 0 per token.
|
|
210
|
-
function unpriced(providers,
|
|
211
|
-
const named = new Set(
|
|
234
|
+
function unpriced(providers, chains) {
|
|
235
|
+
const named = new Set(chains.flatMap((chain) => chain ?? []));
|
|
212
236
|
return [...named].filter((model) => {
|
|
213
237
|
const slash = model.indexOf("/");
|
|
214
238
|
const price = providers[model.slice(0, slash)]?.models[model.slice(slash + 1)];
|
|
@@ -20,7 +20,7 @@ export function renderPlan(preview) {
|
|
|
20
20
|
lines.push("", `Review tasks: ${preview.tasks.length}`);
|
|
21
21
|
const width = Math.max(0, ...preview.tasks.map((t) => t.taskId.length));
|
|
22
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.effort === undefined ? "" : ` effort ${t.effort}`} ${t.bundle}`);
|
|
23
|
+
lines.push(` ${t.taskId.padEnd(width)} ~${number.format(t.promptTokens)} prompt tokens${t.planPromptTokens === undefined ? "" : ` + plan ~${number.format(t.planPromptTokens)}`}${t.effort === undefined ? "" : ` effort ${t.effort}`}${t.models === undefined ? "" : ` models ${t.models.join(", ")}`} ${t.bundle}`);
|
|
24
24
|
}
|
|
25
25
|
if (preview.skipped.length > 0) {
|
|
26
26
|
lines.push("", "Skipped by the review matrix:");
|
package/dist/review/plugins.d.ts
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
1
|
import type { OcraPlugin } from "@open-cr-agent/core";
|
|
2
2
|
export declare function loadExternalPlugins(specifiers: readonly string[], root: string): Promise<OcraPlugin[]>;
|
|
3
|
+
/**
|
|
4
|
+
* The plugins an ocra Cloud account names that this machine allowed: each
|
|
5
|
+
* from the allowed directory alone, at the version and integrity recorded
|
|
6
|
+
* when it was allowed. Anything else is skipped with a warning; an account
|
|
7
|
+
* plugin never fails the review.
|
|
8
|
+
*/
|
|
9
|
+
export declare function loadAccountPlugins(names: readonly string[], dir: string, warn: (message: string) => void): Promise<{
|
|
10
|
+
name: string;
|
|
11
|
+
plugin: OcraPlugin;
|
|
12
|
+
}[]>;
|
|
3
13
|
//# sourceMappingURL=plugins.d.ts.map
|
package/dist/review/plugins.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
1
2
|
import { createRequire } from "node:module";
|
|
2
|
-
import { isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
3
4
|
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { installedEntry, readAllowed } from "../plugin-store.js";
|
|
4
6
|
import { ConfigError } from "./config.js";
|
|
5
7
|
// Plugins listed in the repository config execute code, so they are resolved
|
|
6
8
|
// only from the repository's own dependencies or relative paths inside it.
|
|
@@ -29,4 +31,47 @@ function resolvePlugin(specifier, root) {
|
|
|
29
31
|
function isPlugin(value) {
|
|
30
32
|
return (typeof value === "object" && value !== null && typeof value.name === "string");
|
|
31
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* The plugins an ocra Cloud account names that this machine allowed: each
|
|
36
|
+
* from the allowed directory alone, at the version and integrity recorded
|
|
37
|
+
* when it was allowed. Anything else is skipped with a warning; an account
|
|
38
|
+
* plugin never fails the review.
|
|
39
|
+
*/
|
|
40
|
+
export async function loadAccountPlugins(names, dir, warn) {
|
|
41
|
+
const allowed = new Map((await readAllowed(dir)).map((p) => [p.name, p]));
|
|
42
|
+
const missing = names.filter((name) => !allowed.has(name));
|
|
43
|
+
if (missing.length > 0) {
|
|
44
|
+
warn(`your ocra Cloud settings name plugins this machine has not allowed, so they do not load: ${missing.join(", ")}; allow each with ocra plugins allow <name>@<version>`);
|
|
45
|
+
}
|
|
46
|
+
const loaded = [];
|
|
47
|
+
for (const name of names) {
|
|
48
|
+
const record = allowed.get(name);
|
|
49
|
+
if (!record)
|
|
50
|
+
continue;
|
|
51
|
+
try {
|
|
52
|
+
const installed = await installedEntry(dir, name);
|
|
53
|
+
if (installed?.version !== record.version || installed.integrity !== record.integrity) {
|
|
54
|
+
throw new Error(`it is not the ${record.version} allowed here; run ocra plugins allow ${name}@<version> again`);
|
|
55
|
+
}
|
|
56
|
+
const module = await import(pathToFileURL(await allowedEntry(dir, name)).href);
|
|
57
|
+
const plugin = module.default ?? module.plugin;
|
|
58
|
+
if (!isPlugin(plugin))
|
|
59
|
+
throw new Error('it exports no ocra plugin as default or "plugin"');
|
|
60
|
+
loaded.push({ name, plugin });
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
warn(`skipping plugin ${name} from your ocra Cloud settings: ${error.message}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return loaded;
|
|
67
|
+
}
|
|
68
|
+
// Node's resolution walks up past the directory to any node_modules above
|
|
69
|
+
// it; the entry must be inside the allowed package itself.
|
|
70
|
+
async function allowedEntry(dir, name) {
|
|
71
|
+
const entry = await realpath(createRequire(join(dir, "package.json")).resolve(name));
|
|
72
|
+
const root = await realpath(join(dir, "node_modules", name));
|
|
73
|
+
if (!entry.startsWith(`${root}${sep}`))
|
|
74
|
+
throw new Error(`it resolves outside ${dir}`);
|
|
75
|
+
return entry;
|
|
76
|
+
}
|
|
32
77
|
//# sourceMappingURL=plugins.js.map
|
|
@@ -14,10 +14,20 @@ export function requestedSampling(config, args) {
|
|
|
14
14
|
// environment variables, never their values; a password written into a
|
|
15
15
|
// URL is dropped. Sampling is recorded on its own. Effort is covered; left
|
|
16
16
|
// unset it adds nothing, so configurations that set none keep their hash.
|
|
17
|
+
// The ocra Cloud account settings (ADR-0027) add their version and rules
|
|
18
|
+
// only when they were layered in, so a signed-out run keeps its hash.
|
|
17
19
|
export function configHash(config, args) {
|
|
18
|
-
|
|
20
|
+
// runtimeSet says where the runtime came from, not what ran: the runtime is hashed.
|
|
21
|
+
const { sampling: _, runtimeSet: _set, providers, extends: shared, effort, roles, rules, accountSettings, ...rest } = config;
|
|
22
|
+
const bare = (account) => rules
|
|
23
|
+
.filter((rule) => (rule.source === "account") === account)
|
|
24
|
+
.map(({ path, rule }) => ({ path, rule }));
|
|
19
25
|
return stableHash({
|
|
20
26
|
...rest,
|
|
27
|
+
rules: bare(false),
|
|
28
|
+
...(accountSettings
|
|
29
|
+
? { account: { version: accountSettings.version, rules: bare(true) } }
|
|
30
|
+
: {}),
|
|
21
31
|
...(Object.keys(effort).length > 0 ? { effort } : {}),
|
|
22
32
|
...(Object.keys(roles).length > 0 ? { roles } : {}),
|
|
23
33
|
...(shared === undefined ? {} : { extends: withoutCredentials(shared) }),
|
package/dist/review/render.js
CHANGED
|
@@ -55,8 +55,13 @@ export function renderText(report, sessionDir) {
|
|
|
55
55
|
if (refuted.length > 0) {
|
|
56
56
|
lines.push(`Verification dropped ${refuted.length} finding(s) the code disproves (see the JSON report).`);
|
|
57
57
|
}
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
const fromRepository = report.remembered.filter((e) => e.source === "repository").length;
|
|
59
|
+
const fromAccount = report.remembered.length - fromRepository;
|
|
60
|
+
if (fromRepository > 0) {
|
|
61
|
+
lines.push(`${fromRepository} finding(s) matched the repository's memory and were not reported.`);
|
|
62
|
+
}
|
|
63
|
+
if (fromAccount > 0) {
|
|
64
|
+
lines.push(`${fromAccount} finding(s) matched your ocra Cloud memory and were not reported.`);
|
|
60
65
|
}
|
|
61
66
|
const rereview = report.rereview;
|
|
62
67
|
if (report.scope?.mode === "incremental") {
|