@consensus-tools/consensus-tools 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -1,10 +1,6 @@
1
1
  # consensus.tools
2
2
 
3
- <p align="center">
4
- <picture>
5
- <img src="https://raw.githubusercontent.com/consensus-tools/consensus-tools/main/assets/consensus-tools" alt="consensus-tools" width="500">
6
- </picture>
7
- </p>
3
+ ![consensus-tools](./assets/consensus-tools)
8
4
 
9
5
  **High-confidence decisions for agentic systems.**
10
6
  Local-first. Incentive-aligned. Verifiable.
@@ -89,7 +85,7 @@ const job = await openclaw.consensus.jobs.post({
89
85
  policy: "HIGHEST_CONFIDENCE_SINGLE",
90
86
  reward: 8,
91
87
  stake: 4,
92
- leaseSeconds: 180
88
+ expiresSeconds: 180
93
89
  });
94
90
  ```
95
91
 
@@ -131,6 +127,7 @@ When you’re ready, point the same CLI at a hosted board:
131
127
  export CONSENSUS_MODE=remote
132
128
  export CONSENSUS_URL=https://api.consensus.tools
133
129
  export CONSENSUS_BOARD_ID=board_abc123
130
+ export CONSENSUS_API_KEY_ENV=CONSENSUS_API_KEY
134
131
  export CONSENSUS_API_KEY=...
135
132
  ```
136
133
 
@@ -143,12 +140,18 @@ Same guarantees.
143
140
  ## CLI
144
141
 
145
142
  ```sh
146
- consensus init
147
- consensus board use local|remote
148
- consensus jobs post
149
- consensus submissions create <jobId>
150
- consensus votes cast <jobId>
151
- consensus resolve <jobId>
143
+ # Standalone CLI
144
+ npm i -g @consensus-tools/consensus-tools
145
+ consensus-tools init
146
+ consensus-tools board use local|remote
147
+ consensus-tools jobs post
148
+ consensus-tools submissions create <jobId>
149
+ consensus-tools votes cast <jobId>
150
+ consensus-tools resolve <jobId>
151
+
152
+ # OpenClaw plugin CLI
153
+ openclaw consensus init
154
+ openclaw consensus jobs list
152
155
  ```
153
156
 
154
157
  The CLI generates .sh API templates so everything is scriptable and inspectable.
@@ -356,10 +359,10 @@ Build systems that deserve trust.
356
359
  This plugin is packaged to work with `openclaw plugins install`:
357
360
 
358
361
  ```
359
- openclaw plugins install @openclaw/consensus-tools
362
+ openclaw plugins install @consensus-tools/consensus-tools
360
363
  ```
361
364
 
362
- The package includes `openclaw.extensions` pointing at `./index.ts`, so OpenClaw will load it as a plugin. The interaction skill is kept separately under `extensions/consensus-tools-interact/`.
365
+ The package includes `openclaw.extensions` pointing at `./index.ts`, so OpenClaw will load it as a plugin. The interaction skill is kept separately under `extensions/consensus-interact/`.
363
366
 
364
367
  ## Configure
365
368
 
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from 'node:child_process';
3
+ import { createRequire } from 'node:module';
4
+ import path from 'node:path';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
+
7
+ const require = createRequire(import.meta.url);
8
+
9
+ function nodeSupportsImportFlag() {
10
+ const [major, minor] = process.versions.node.split('.').map((n) => Number(n));
11
+ if (!Number.isFinite(major) || !Number.isFinite(minor)) return true;
12
+ // --import was added in Node 20.6.0 and 18.19.0.
13
+ if (major > 20) return true;
14
+ if (major === 20) return minor >= 6;
15
+ if (major === 19) return true;
16
+ if (major === 18) return minor >= 19;
17
+ return false;
18
+ }
19
+
20
+ const tsxLoaderPath = require.resolve('tsx');
21
+ const __filename = fileURLToPath(import.meta.url);
22
+ const __dirname = path.dirname(__filename);
23
+ const entry = path.join(__dirname, '..', 'src', 'standalone.ts');
24
+
25
+ const loaderSpecifier = pathToFileURL(tsxLoaderPath).href;
26
+ const nodeArgs = nodeSupportsImportFlag() ? ['--import', loaderSpecifier] : ['--loader', loaderSpecifier];
27
+
28
+ const result = spawnSync(process.execPath, [...nodeArgs, entry, ...process.argv.slice(2)], {
29
+ stdio: 'inherit',
30
+ env: process.env
31
+ });
32
+
33
+ process.exit(result.status ?? 1);
34
+
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "consensus-tools",
3
3
  "name": "consensus-tools",
4
- "version": "0.1.0",
4
+ "version": "0.1.1",
5
5
  "main": "index.ts",
6
6
  "description": "consensus-tools distributed job board for OpenClaw agents",
7
7
  "configSchema": {
package/package.json CHANGED
@@ -1,27 +1,32 @@
1
1
  {
2
2
  "name": "@consensus-tools/consensus-tools",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
+ "bin": {
7
+ "consensus-tools": "bin/consensus-tools.js"
8
+ },
6
9
  "openclaw": {
7
- "extensions": ["./index.ts"]
10
+ "extensions": [
11
+ "./index.ts"
12
+ ]
8
13
  },
9
14
  "scripts": {
10
- "test": "tsx --test"
15
+ "test": "node --test --import tsx"
11
16
  },
12
17
  "files": [
18
+ "bin",
13
19
  "index.ts",
14
20
  "src",
15
21
  "openclaw.plugin.json",
16
22
  "README.md"
17
23
  ],
18
24
  "dependencies": {
19
- "ajv": "^8.12.0"
20
- },
21
- "devDependencies": {
25
+ "ajv": "^8.12.0",
22
26
  "openai": "^4.79.0",
23
27
  "tsx": "^4.19.2"
24
28
  },
29
+ "devDependencies": {},
25
30
  "optionalDependencies": {
26
31
  "better-sqlite3": "^9.4.3"
27
32
  }
package/src/cli.ts CHANGED
@@ -1,9 +1,18 @@
1
- import { readFileSync, promises as fs } from 'node:fs';
1
+ import { promises as fs } from 'node:fs';
2
2
  import path from 'node:path';
3
- import os from 'node:os';
4
3
  import type { ConsensusToolsConfig, Job } from './types';
5
4
  import { renderTable } from './util/table';
6
- import { runConsensusPolicyTests } from '../tests/runner/consensusTestRunner';
5
+ import { runConsensusPolicyTests } from './testing/consensusTestRunner';
6
+ import { runInitWizard } from './initWizard';
7
+ import {
8
+ defaultConsensusCliConfig,
9
+ getConfigValue,
10
+ loadCliConfig,
11
+ parseValue,
12
+ saveCliConfig,
13
+ setConfigValue,
14
+ type ConsensusCliConfig
15
+ } from './cliConfig';
7
16
 
8
17
  export interface ConsensusToolsBackendCli {
9
18
  postJob(agentId: string, input: any): Promise<Job>;
@@ -17,15 +26,67 @@ export interface ConsensusToolsBackendCli {
17
26
  resolveJob(agentId: string, jobId: string, input: any): Promise<any>;
18
27
  }
19
28
 
29
+ export async function initRepo(opts: {
30
+ rootDir?: string;
31
+ force?: boolean;
32
+ wizard?: boolean;
33
+ templatesOnly?: boolean;
34
+ }): Promise<void> {
35
+ const rootDir = opts.rootDir || process.cwd();
36
+ const force = Boolean(opts.force);
37
+ const templatesOnly = Boolean(opts.templatesOnly);
38
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
39
+ const wizard = typeof opts.wizard === 'boolean' ? opts.wizard : interactive;
40
+
41
+ if (templatesOnly || !wizard) {
42
+ await writeInitTemplates(rootDir, force, defaultConsensusCliConfig);
43
+ return;
44
+ }
45
+
46
+ if (!interactive) {
47
+ throw new Error('init wizard requires a TTY. Re-run with --templates-only.');
48
+ }
49
+
50
+ const result = await runInitWizard(rootDir);
51
+ await writeInitTemplates(rootDir, force, result.config);
52
+ if (result.env) {
53
+ await writeEnvFile(rootDir, force, result.env);
54
+ }
55
+ }
56
+
20
57
  export function registerCli(program: any, backend: ConsensusToolsBackendCli, config: ConsensusToolsConfig, agentId: string) {
21
58
  const consensus = program.command('consensus').description('Consensus tools');
59
+ registerConsensusSubcommands(consensus, backend, config, agentId);
60
+ }
22
61
 
62
+ export function registerStandaloneCli(
63
+ program: any,
64
+ backend: ConsensusToolsBackendCli,
65
+ config: ConsensusToolsConfig,
66
+ agentId: string
67
+ ) {
68
+ registerConsensusSubcommands(program, backend, config, agentId);
69
+ }
70
+
71
+ function registerConsensusSubcommands(
72
+ consensus: any,
73
+ backend: ConsensusToolsBackendCli,
74
+ _config: ConsensusToolsConfig,
75
+ agentId: string
76
+ ) {
23
77
  consensus
24
78
  .command('init')
25
- .description('Generate .consensus shell templates')
79
+ .description('Initialize consensus-tools in this repo (.consensus/)')
26
80
  .option('--force', 'Overwrite existing files')
81
+ .option('--wizard', 'Run an interactive wizard (default when TTY)')
82
+ .option('--templates-only', 'Only generate templates; skip prompts')
27
83
  .action(async (opts: any) => {
28
- await writeInitTemplates(process.cwd(), Boolean(opts.force));
84
+ await initRepo({
85
+ rootDir: process.cwd(),
86
+ force: Boolean(opts.force),
87
+ wizard: typeof opts.wizard === 'boolean' ? opts.wizard : undefined,
88
+ templatesOnly: Boolean(opts.templatesOnly)
89
+ });
29
90
  console.log('Created .consensus templates.');
30
91
  });
31
92
 
@@ -34,7 +95,7 @@ export function registerCli(program: any, backend: ConsensusToolsBackendCli, con
34
95
  .command('get <key>')
35
96
  .description('Get a config value')
36
97
  .action(async (key: string) => {
37
- const cfg = await loadConfigFile();
98
+ const cfg = await loadCliConfig();
38
99
  const value = getConfigValue(cfg, key);
39
100
  output(value ?? null, true);
40
101
  });
@@ -43,10 +104,10 @@ export function registerCli(program: any, backend: ConsensusToolsBackendCli, con
43
104
  .command('set <key> <value>')
44
105
  .description('Set a config value')
45
106
  .action(async (key: string, value: string) => {
46
- const cfg = await loadConfigFile();
107
+ const cfg = await loadCliConfig();
47
108
  const parsed = parseValue(value);
48
109
  setConfigValue(cfg, key, parsed);
49
- await saveConfigFile(cfg);
110
+ await saveCliConfig(cfg);
50
111
  output({ ok: true }, true);
51
112
  });
52
113
 
@@ -55,7 +116,7 @@ export function registerCli(program: any, backend: ConsensusToolsBackendCli, con
55
116
  .command('use <type> [url]')
56
117
  .description('Select local or remote board')
57
118
  .action(async (type: string, url?: string) => {
58
- const cfg = await loadConfigFile();
119
+ const cfg = await loadCliConfig();
59
120
  if (type !== 'local' && type !== 'remote') {
60
121
  throw new Error('board type must be local or remote');
61
122
  }
@@ -63,7 +124,7 @@ export function registerCli(program: any, backend: ConsensusToolsBackendCli, con
63
124
  if (type === 'remote' && url) {
64
125
  cfg.boards.remote.url = url;
65
126
  }
66
- await saveConfigFile(cfg);
127
+ await saveCliConfig(cfg);
67
128
  output({ activeBoard: cfg.activeBoard, url: cfg.boards.remote.url }, true);
68
129
  });
69
130
 
@@ -78,7 +139,7 @@ export function registerCli(program: any, backend: ConsensusToolsBackendCli, con
78
139
  .option('--policy <policy>', 'Policy key')
79
140
  .option('--reward <n>', 'Reward amount', parseFloat)
80
141
  .option('--stake <n>', 'Stake amount', parseFloat)
81
- .option('--lease <seconds>', 'Lease seconds', parseInt)
142
+ .option('--expires <seconds>', 'Expires seconds', parseInt)
82
143
  .option('--json', 'JSON output')
83
144
  .action(async (opts: any) => {
84
145
  const input = opts.input ?? (await readStdinIfAny());
@@ -93,7 +154,7 @@ export function registerCli(program: any, backend: ConsensusToolsBackendCli, con
93
154
  stakeAmount: opts.stake,
94
155
  reward: opts.reward,
95
156
  stakeRequired: opts.stake,
96
- expiresSeconds: opts.lease
157
+ expiresSeconds: opts.expires
97
158
  });
98
159
  output(job, opts.json);
99
160
  });
@@ -228,7 +289,7 @@ export function registerCli(program: any, backend: ConsensusToolsBackendCli, con
228
289
  .command('run')
229
290
  .description('Run consensus policy tests with generation script')
230
291
  .option('--agents <n>', 'Number of agent personalities', parseInt)
231
- .option('--script <path>', 'Path to generation script', 'tests/runner/generation.ts')
292
+ .option('--script <path>', 'Path to generation script', '.consensus/generation.ts')
232
293
  .option('--openai-key <key>', 'OpenAI API key (or set OPENAI_API_KEY)')
233
294
  .option('--model <name>', 'Model name', 'gpt-5.2')
234
295
  .action(async (opts: any) => {
@@ -270,89 +331,14 @@ async function readStdinIfAny(): Promise<string | undefined> {
270
331
  return text || undefined;
271
332
  }
272
333
 
273
- type ConsensusConfig = {
274
- activeBoard: 'local' | 'remote';
275
- boards: {
276
- local: { type: 'local'; root: string; jobsPath: string; ledgerPath: string };
277
- remote: { type: 'remote'; url: string; boardId: string; auth: { type: 'apiKey'; apiKeyEnv: string } };
278
- };
279
- defaults: { policy: string; reward: number; stake: number; leaseSeconds: number };
280
- };
281
-
282
- const defaultConsensusConfig: ConsensusConfig = {
283
- activeBoard: 'local',
284
- boards: {
285
- local: {
286
- type: 'local',
287
- root: '~/.openclaw/workplace/consensus-board',
288
- jobsPath: 'jobs',
289
- ledgerPath: 'ledger.json'
290
- },
291
- remote: {
292
- type: 'remote',
293
- url: 'https://api.consensus.tools',
294
- boardId: 'board_replace_me',
295
- auth: { type: 'apiKey', apiKeyEnv: 'CONSENSUS_API_KEY' }
296
- }
297
- },
298
- defaults: {
299
- policy: 'HIGHEST_CONFIDENCE_SINGLE',
300
- reward: 8,
301
- stake: 4,
302
- leaseSeconds: 180
303
- }
304
- };
305
-
306
- function configPath(): string {
307
- const envPath = process.env.CONSENSUS_CONFIG;
308
- if (envPath) return expandHome(envPath);
309
- return path.join(os.homedir(), '.consensus', 'config.json');
310
- }
311
-
312
- function expandHome(input: string): string {
313
- if (!input.startsWith('~')) return input;
314
- return path.join(os.homedir(), input.slice(1));
315
- }
316
-
317
- async function loadConfigFile(): Promise<ConsensusConfig> {
318
- const filePath = configPath();
319
- try {
320
- const raw = await fs.readFile(filePath, 'utf8');
321
- return JSON.parse(raw) as ConsensusConfig;
322
- } catch {
323
- return JSON.parse(JSON.stringify(defaultConsensusConfig)) as ConsensusConfig;
324
- }
325
- }
326
-
327
- async function saveConfigFile(config: ConsensusConfig): Promise<void> {
328
- const filePath = configPath();
329
- await fs.mkdir(path.dirname(filePath), { recursive: true });
330
- await fs.writeFile(filePath, JSON.stringify(config, null, 2), 'utf8');
331
- }
332
-
333
- function getConfigValue(config: any, key: string): any {
334
- return key.split('.').reduce((acc, part) => (acc ? acc[part] : undefined), config);
334
+ async function writeEnvFile(rootDir: string, force: boolean, env: Record<string, string>): Promise<void> {
335
+ const filePath = path.join(rootDir, '.consensus', '.env');
336
+ const lines = Object.entries(env).map(([k, v]) => `export ${k}=${v}`);
337
+ const content = [...lines, ''].join('\n');
338
+ await writeFile(filePath, content, force);
335
339
  }
336
340
 
337
- function setConfigValue(config: any, key: string, value: any): void {
338
- const parts = key.split('.');
339
- let cur = config as any;
340
- for (let i = 0; i < parts.length - 1; i += 1) {
341
- if (!cur[parts[i]]) cur[parts[i]] = {};
342
- cur = cur[parts[i]];
343
- }
344
- cur[parts[parts.length - 1]] = value;
345
- }
346
-
347
- function parseValue(input: string): any {
348
- try {
349
- return JSON.parse(input);
350
- } catch {
351
- return input;
352
- }
353
- }
354
-
355
- async function writeInitTemplates(rootDir: string, force: boolean): Promise<void> {
341
+ async function writeInitTemplates(rootDir: string, force: boolean, config: ConsensusCliConfig): Promise<void> {
356
342
  const baseDir = path.join(rootDir, '.consensus');
357
343
  const apiDir = path.join(baseDir, 'api');
358
344
 
@@ -360,8 +346,10 @@ async function writeInitTemplates(rootDir: string, force: boolean): Promise<void
360
346
 
361
347
  const files: Array<{ path: string; content: string; executable?: boolean }> = [
362
348
  { path: path.join(baseDir, 'README.md'), content: consensusReadme() },
363
- { path: path.join(baseDir, 'env.example'), content: envExample() },
364
- { path: path.join(baseDir, 'config.json'), content: JSON.stringify(defaultConsensusConfig, null, 2) },
349
+ { path: path.join(baseDir, 'env.example'), content: envExample(config) },
350
+ { path: path.join(baseDir, '.gitignore'), content: ['.env', ''].join('\n') },
351
+ { path: path.join(baseDir, 'config.json'), content: JSON.stringify(config, null, 2) },
352
+ { path: path.join(baseDir, 'generation.ts'), content: generationScriptTemplate() },
365
353
  { path: path.join(apiDir, 'common.sh'), content: commonSh(), executable: true },
366
354
  { path: path.join(apiDir, 'jobs_post.sh'), content: jobsPostSh(), executable: true },
367
355
  { path: path.join(apiDir, 'jobs_get.sh'), content: jobsGetSh(), executable: true },
@@ -397,7 +385,7 @@ function consensusReadme(): string {
397
385
  return [
398
386
  '# consensus.tools shell templates',
399
387
  '',
400
- 'This folder is generated by `consensus init`.',
388
+ 'This folder is generated by `consensus-tools init` (or `openclaw consensus init`).',
401
389
  '',
402
390
  '## Quick start',
403
391
  '',
@@ -426,6 +414,13 @@ function consensusReadme(): string {
426
414
  'bash .consensus/api/jobs_post.sh "Title" "Desc" "Input"',
427
415
  '```',
428
416
  '',
417
+ 'Try the CLI:',
418
+ '',
419
+ '```bash',
420
+ 'consensus-tools jobs list',
421
+ 'consensus-tools jobs post --title "Hello" --desc "World" --input "Test"',
422
+ '```',
423
+ '',
429
424
  'Notes',
430
425
  '',
431
426
  'Local mode writes to CONSENSUS_ROOT (defaults in env.example).',
@@ -437,24 +432,129 @@ function consensusReadme(): string {
437
432
  ].join('\n');
438
433
  }
439
434
 
440
- function envExample(): string {
435
+ function envExample(config: ConsensusCliConfig): string {
436
+ const apiKeyEnv = config.boards.remote.auth.apiKeyEnv || 'CONSENSUS_API_KEY';
441
437
  return [
442
438
  '# Mode: "local" or "remote"',
443
- 'export CONSENSUS_MODE=local',
439
+ `export CONSENSUS_MODE=${config.activeBoard === 'remote' ? 'remote' : 'local'}`,
440
+ '',
441
+ '# Agent id (used by the CLI; optional)',
442
+ 'export CONSENSUS_AGENT_ID="cli@your-machine"',
444
443
  '',
445
444
  '# Local board root (JSON filesystem board)',
446
- 'export CONSENSUS_ROOT="$HOME/.openclaw/workplace/consensus-board"',
445
+ `export CONSENSUS_ROOT="${config.boards.local.root}"`,
447
446
  '',
448
447
  '# Remote board settings',
449
- 'export CONSENSUS_URL="https://api.consensus.tools"',
450
- 'export CONSENSUS_BOARD_ID="board_replace_me"',
451
- 'export CONSENSUS_API_KEY="replace_me"',
448
+ `export CONSENSUS_URL="${config.boards.remote.url}"`,
449
+ `export CONSENSUS_BOARD_ID="${config.boards.remote.boardId}"`,
450
+ `export CONSENSUS_API_KEY_ENV="${apiKeyEnv}"`,
451
+ `export ${apiKeyEnv}="replace_me"`,
452
452
  '',
453
453
  '# Defaults (used by jobs_post.sh if not provided)',
454
- 'export CONSENSUS_DEFAULT_POLICY="HIGHEST_CONFIDENCE_SINGLE"',
455
- 'export CONSENSUS_DEFAULT_REWARD="8"',
456
- 'export CONSENSUS_DEFAULT_STAKE="4"',
457
- 'export CONSENSUS_DEFAULT_LEASE_SECONDS="180"',
454
+ `export CONSENSUS_DEFAULT_POLICY="${config.defaults.policy}"`,
455
+ `export CONSENSUS_DEFAULT_REWARD="${config.defaults.reward}"`,
456
+ `export CONSENSUS_DEFAULT_STAKE="${config.defaults.stake}"`,
457
+ `export CONSENSUS_DEFAULT_LEASE_SECONDS="${config.defaults.leaseSeconds}"`,
458
+ ''
459
+ ].join('\n');
460
+ }
461
+
462
+ function generationScriptTemplate(): string {
463
+ return [
464
+ '// Generated by consensus-tools init.',
465
+ '// Customize this file, then run:',
466
+ '// consensus-tools tests run --agents 6 --script .consensus/generation.ts',
467
+ '',
468
+ '/**',
469
+ ' * This script is intentionally deterministic by default (mockResponse).',
470
+ ' * If you provide OPENAI_API_KEY (or --openai-key), the runner will call OpenAI.',
471
+ ' */',
472
+ 'const script = {',
473
+ " name: 'rx_negation_demo',",
474
+ ' task: {',
475
+ " title: 'Negation test: prescription advancement',",
476
+ " desc: 'Tell us why this insulin amount is NOT enough based on patient file, insurance reqs, and doctor notes.',",
477
+ ' input: [',
478
+ " 'We want to negation test prescription advancement for diabetic patients.',",
479
+ " 'Explain why the insulin amount is not enough based on:',",
480
+ " '- patient file',",
481
+ " '- insurance info/requirements',",
482
+ " '- doctor notes',",
483
+ " '',",
484
+ " 'Patient file: (paste here)',",
485
+ " '',",
486
+ " 'Insurance info/requirements: (paste here)',",
487
+ " '',",
488
+ " 'Doctor notes: (paste here)'",
489
+ ' ].join(\"\\n\")',
490
+ ' },',
491
+ " expectedAnswer: 'INSUFFICIENT',",
492
+ ' personas: [],',
493
+ ' getPersonas(count) {',
494
+ ' const n = Math.max(3, Number(count || 3));',
495
+ ' const third = Math.ceil(n / 3);',
496
+ '',
497
+ ' const mk = (role, i, systemPrompt, personaRole) => ({',
498
+ " id: `${role}_${i + 1}`,",
499
+ " name: `${role.toUpperCase()} Agent ${i + 1}`,",
500
+ ' systemPrompt,',
501
+ ' role: personaRole',
502
+ ' });',
503
+ '',
504
+ ' const doctors = Array.from({ length: third }, (_, i) =>',
505
+ ' mk(',
506
+ " 'doctor',",
507
+ ' i,',
508
+ " 'You are a practicing clinician. Be precise. Use only the provided case context. Focus on medical necessity.',",
509
+ " 'accurate'",
510
+ ' )',
511
+ ' );',
512
+ ' const support = Array.from({ length: third }, (_, i) =>',
513
+ ' mk(',
514
+ " 'support',",
515
+ ' i,',
516
+ " 'You are customer support at a pharmacy benefits manager. Focus on process, eligibility, required docs, and next steps.',",
517
+ " 'accurate'",
518
+ ' )',
519
+ ' );',
520
+ ' const insurance = Array.from({ length: n - 2 * third }, (_, i) =>',
521
+ ' mk(',
522
+ " 'insurance',",
523
+ ' i,',
524
+ " 'You are an insurance reviewer. Apply coverage criteria and utilization management rules. Be skeptical and cite requirements.',",
525
+ // Make the last insurance persona contrarian to ensure policies see disagreement.
526
+ " i === (n - 2 * third) - 1 ? 'contrarian' : 'accurate'",
527
+ ' )',
528
+ ' );',
529
+ '',
530
+ ' return [...doctors, ...support, ...insurance].slice(0, n);',
531
+ ' },',
532
+ ' buildPrompt(persona, task, expectedAnswer) {',
533
+ ' return {',
534
+ ' system: persona.systemPrompt,',
535
+ ' user: [',
536
+ " 'Return JSON: {\"answer\": string, \"confidence\": number, \"evidence\": string[]}',",
537
+ " '',",
538
+ " `TASK: ${task.title}`,",
539
+ " task.desc ? `DESC: ${task.desc}` : '',",
540
+ " '',",
541
+ " `INPUT:\\n${task.input}`,",
542
+ " '',",
543
+ " `EXPECTED (for negation testing): ${expectedAnswer}`",
544
+ ' ].filter(Boolean).join(\"\\n\")',
545
+ ' };',
546
+ ' },',
547
+ ' mockResponse(persona, task, expectedAnswer) {',
548
+ ' const answer = persona.role === \"contrarian\" ? \"SUFFICIENT\" : expectedAnswer;',
549
+ ' return JSON.stringify({',
550
+ ' answer,',
551
+ ' confidence: persona.role === \"contrarian\" ? 0.2 : 0.9,',
552
+ ' evidence: [persona.name, task.title]',
553
+ ' });',
554
+ ' }',
555
+ '};',
556
+ '',
557
+ 'export default script;',
458
558
  ''
459
559
  ].join('\n');
460
560
  }
@@ -509,9 +609,14 @@ function commonSh(): string {
509
609
  ' echo "${CONSENSUS_URL%/}/v1/boards/${CONSENSUS_BOARD_ID}"',
510
610
  '}',
511
611
  '',
612
+ 'api_key_env() {',
613
+ ' echo "${CONSENSUS_API_KEY_ENV:-CONSENSUS_API_KEY}"',
614
+ '}',
615
+ '',
512
616
  'remote_auth_header() {',
513
- ' require_env "CONSENSUS_API_KEY"',
514
- ' echo "Authorization: Bearer ${CONSENSUS_API_KEY}"',
617
+ ' local name; name="$(api_key_env)"',
618
+ ' require_env "$name"',
619
+ ' echo "Authorization: Bearer ${!name}"',
515
620
  '}',
516
621
  '',
517
622
  'curl_json() {',