@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.
Files changed (39) hide show
  1. package/dist/account-salt.d.ts +7 -0
  2. package/dist/account-salt.js +43 -0
  3. package/dist/cloud.d.ts +2 -0
  4. package/dist/cloud.js +11 -1
  5. package/dist/plugin-store.d.ts +20 -0
  6. package/dist/plugin-store.js +84 -0
  7. package/dist/plugins-command.d.ts +15 -0
  8. package/dist/plugins-command.js +183 -0
  9. package/dist/review/account-plugins.d.ts +23 -0
  10. package/dist/review/account-plugins.js +54 -0
  11. package/dist/review/account-settings.d.ts +100 -0
  12. package/dist/review/account-settings.js +106 -0
  13. package/dist/review/cloud-findings.d.ts +31 -0
  14. package/dist/review/cloud-findings.js +80 -0
  15. package/dist/review/cloud-memory.d.ts +6 -0
  16. package/dist/review/cloud-memory.js +58 -0
  17. package/dist/review/cloud-providers.d.ts +2 -2
  18. package/dist/review/cloud-providers.js +3 -2
  19. package/dist/review/cloud-review.d.ts +19 -0
  20. package/dist/review/cloud-review.js +51 -0
  21. package/dist/review/cloud-settings.d.ts +16 -0
  22. package/dist/review/cloud-settings.js +118 -0
  23. package/dist/review/cloud-upload.d.ts +26 -6
  24. package/dist/review/cloud-upload.js +82 -37
  25. package/dist/review/command.d.ts +2 -0
  26. package/dist/review/command.js +81 -28
  27. package/dist/review/config.d.ts +17 -6
  28. package/dist/review/config.js +31 -7
  29. package/dist/review/plan-render.js +1 -1
  30. package/dist/review/plugins.d.ts +10 -0
  31. package/dist/review/plugins.js +46 -1
  32. package/dist/review/provenance.js +11 -1
  33. package/dist/review/render.js +7 -2
  34. package/dist/review/settings-sources.d.ts +11 -0
  35. package/dist/review/settings-sources.js +58 -0
  36. package/dist/review/target.d.ts +1 -0
  37. package/dist/review/target.js +3 -0
  38. package/dist/run.js +19 -0
  39. 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. Never a path, a title, a finding's text or code. The repository
12
- // is a hash salted with a value that stays on this machine, so the server
13
- // can group reviews of one repository without learning which it is.
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
- /** A random salt kept beside the credentials, made on first use. */
64
- async function salt(credentialsPath) {
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
- export async function repoHash(root, credentialsPath) {
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(`${await salt(credentialsPath)}:${await repositoryId(root)}`)
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
- /** Sends the counts; a failure is a warning, never a failed review. */
87
- export async function uploadReview(upload, deps, warn) {
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 false;
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
- body: JSON.stringify(upload),
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 false;
169
+ return undefined;
105
170
  }
106
- return true;
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 false;
111
- }
112
- }
113
- /** The default models chosen on the web, for a review that configures none. */
114
- export async function defaultModels(deps) {
115
- const session = await cloudSession(deps);
116
- if (!session)
117
- return {};
118
- const res = await deps.fetch(`${session.server}/api/preferences/models`, {
119
- headers: { authorization: `Bearer ${session.access_token}`, "user-agent": `ocra/${VERSION}` },
120
- signal: AbortSignal.timeout(15_000),
121
- });
122
- if (!res.ok)
123
- return {};
124
- const body = (await res.json());
125
- const chains = {};
126
- for (const tier of ["top", "standard", "light"]) {
127
- const list = body.models?.[tier];
128
- if (Array.isArray(list) && list.every((m) => typeof m === "string" && m.startsWith("ocra-"))) {
129
- if (list.length > 0)
130
- chains[tier] = list;
131
- }
177
+ return undefined;
132
178
  }
133
- return chains;
134
179
  }
135
180
  //# sourceMappingURL=cloud-upload.js.map
@@ -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: {
@@ -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 { cloudEnabled, defaultModels, repoHash, uploadOf, uploadReview } from "./cloud-upload.js";
11
- import { ConfigError } from "./config.js";
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
- const { config } = target;
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 registry = await startPlugins([...builtins, ...target.plugins], {
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
- ? config.pluginSettings
54
- : { ...config.pluginSettings, [sessionJsonlPlugin.name]: session },
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
- rules: [...registry.rules, ...config.rules],
106
+ models: config.models,
107
+ rules,
68
108
  selection: { ...defaultSelectionPolicy, include: config.include, exclude: config.exclude },
69
109
  ...(target.readTrusted ? { readTrusted: target.readTrusted } : {}),
70
- ...(args.ultra ? { ultra: true } : {}),
110
+ ...(ultra ? { ultra: true } : {}),
71
111
  ...(config.maxTasks !== undefined ? { maxTasks: config.maxTasks } : {}),
72
112
  });
73
- const rendered = args.format === "json" ? `${safeJson(toPlanOutput(preview))}\n` : renderPlan(preview);
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 cloudDeps = deps.cloud;
83
- const signedIn = cloudDeps !== undefined && (await cloudEnabled(cloudDeps));
84
- let models = config.models;
85
- if (cloudDeps && signedIn && Object.values(models).every((chain) => !chain?.length)) {
86
- models = await defaultModels(cloudDeps);
87
- if (Object.keys(models).length > 0) {
88
- io.err.write("[ocra] No models configured: using your default models from ocra Cloud\n");
89
- }
90
- }
91
- const cloud = await withCloudProviders(models, config.providers, deps.env, cloudDeps, warn);
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: { ocraVersion: VERSION, configHash: configHash(config, args), sampling },
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
- ...(args.ultra ? { ultra: true } : {}),
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: [...registry.rules, ...config.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 (signedIn && cloudDeps && !args.noUpload) {
165
- const source = args.pullRequest ? "github" : args.mergeRequest ? "gitlab" : "local";
166
- const upload = uploadOf(report, source, await repoHash(root, cloudDeps.credentialsPath), deps.now() - started);
167
- if (await uploadReview(upload, cloudDeps, warn)) {
168
- io.err.write("[ocra] Sent this review's counts to ocra Cloud (--no-upload to skip)\n");
169
- }
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;
@@ -1,7 +1,8 @@
1
- import { type CustomProvider, type ModelChains, OcraError, type RepoRule, type TierEfforts } from "@open-cr-agent/core";
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 configSchema: z.ZodObject<{
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.ZodDefault<z.ZodUnion<[z.ZodEnum<{
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: RepoRule[];
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
@@ -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 configSchema = z
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
- runtime: z.enum(BUILTIN_RUNTIMES).or(z.string().min(1)).default("opencode"),
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, models)) {
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, models) {
211
- const named = new Set(Object.values(models).flat());
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:");
@@ -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
@@ -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
- const { sampling: _, providers, extends: shared, effort, roles, ...rest } = config;
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) }),
@@ -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
- if (report.remembered.length > 0) {
59
- lines.push(`${report.remembered.length} finding(s) matched the repository's memory and were not reported.`);
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") {