@consensus-tools/consensus-tools 0.1.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/src/cli.ts ADDED
@@ -0,0 +1,954 @@
1
+ import { readFileSync, promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import type { ConsensusToolsConfig, Job } from './types';
5
+ import { renderTable } from './util/table';
6
+ import { runConsensusPolicyTests } from '../tests/runner/consensusTestRunner';
7
+
8
+ export interface ConsensusToolsBackendCli {
9
+ postJob(agentId: string, input: any): Promise<Job>;
10
+ listJobs(filters?: Record<string, string | undefined>): Promise<Job[]>;
11
+ getJob(jobId: string): Promise<Job | undefined>;
12
+ getStatus?(jobId: string): Promise<any>;
13
+ submitJob(agentId: string, jobId: string, input: any): Promise<any>;
14
+ listSubmissions(jobId: string): Promise<any[]>;
15
+ listVotes(jobId: string): Promise<any[]>;
16
+ vote(agentId: string, jobId: string, input: any): Promise<any>;
17
+ resolveJob(agentId: string, jobId: string, input: any): Promise<any>;
18
+ }
19
+
20
+ export function registerCli(program: any, backend: ConsensusToolsBackendCli, config: ConsensusToolsConfig, agentId: string) {
21
+ const consensus = program.command('consensus').description('Consensus tools');
22
+
23
+ consensus
24
+ .command('init')
25
+ .description('Generate .consensus shell templates')
26
+ .option('--force', 'Overwrite existing files')
27
+ .action(async (opts: any) => {
28
+ await writeInitTemplates(process.cwd(), Boolean(opts.force));
29
+ console.log('Created .consensus templates.');
30
+ });
31
+
32
+ const configCmd = consensus.command('config').description('Manage config');
33
+ configCmd
34
+ .command('get <key>')
35
+ .description('Get a config value')
36
+ .action(async (key: string) => {
37
+ const cfg = await loadConfigFile();
38
+ const value = getConfigValue(cfg, key);
39
+ output(value ?? null, true);
40
+ });
41
+
42
+ configCmd
43
+ .command('set <key> <value>')
44
+ .description('Set a config value')
45
+ .action(async (key: string, value: string) => {
46
+ const cfg = await loadConfigFile();
47
+ const parsed = parseValue(value);
48
+ setConfigValue(cfg, key, parsed);
49
+ await saveConfigFile(cfg);
50
+ output({ ok: true }, true);
51
+ });
52
+
53
+ const board = consensus.command('board').description('Manage active board');
54
+ board
55
+ .command('use <type> [url]')
56
+ .description('Select local or remote board')
57
+ .action(async (type: string, url?: string) => {
58
+ const cfg = await loadConfigFile();
59
+ if (type !== 'local' && type !== 'remote') {
60
+ throw new Error('board type must be local or remote');
61
+ }
62
+ cfg.activeBoard = type;
63
+ if (type === 'remote' && url) {
64
+ cfg.boards.remote.url = url;
65
+ }
66
+ await saveConfigFile(cfg);
67
+ output({ activeBoard: cfg.activeBoard, url: cfg.boards.remote.url }, true);
68
+ });
69
+
70
+ const jobs = consensus.command('jobs').description('Manage jobs');
71
+ jobs
72
+ .command('post')
73
+ .description('Post a new job')
74
+ .requiredOption('--title <title>', 'Job title')
75
+ .option('--desc <desc>', 'Job description')
76
+ .option('--input <input>', 'Job input (string)')
77
+ .option('--mode <mode>', 'Job mode (SUBMISSION or VOTING)')
78
+ .option('--policy <policy>', 'Policy key')
79
+ .option('--reward <n>', 'Reward amount', parseFloat)
80
+ .option('--stake <n>', 'Stake amount', parseFloat)
81
+ .option('--lease <seconds>', 'Lease seconds', parseInt)
82
+ .option('--json', 'JSON output')
83
+ .action(async (opts: any) => {
84
+ const input = opts.input ?? (await readStdinIfAny());
85
+ const job = await backend.postJob(agentId, {
86
+ title: opts.title,
87
+ desc: opts.desc,
88
+ description: opts.desc,
89
+ inputRef: input,
90
+ mode: opts.mode,
91
+ policyKey: opts.policy,
92
+ rewardAmount: opts.reward,
93
+ stakeAmount: opts.stake,
94
+ reward: opts.reward,
95
+ stakeRequired: opts.stake,
96
+ expiresSeconds: opts.lease
97
+ });
98
+ output(job, opts.json);
99
+ });
100
+
101
+ jobs
102
+ .command('get <jobId>')
103
+ .description('Get a job')
104
+ .option('--json', 'JSON output')
105
+ .action(async (jobId: string, opts: any) => {
106
+ const job = await backend.getJob(jobId);
107
+ if (!job) throw new Error('Job not found');
108
+ output(job, opts.json);
109
+ });
110
+
111
+ jobs
112
+ .command('list')
113
+ .description('List jobs')
114
+ .option('--tag <tag>', 'Filter by tag')
115
+ .option('--status <status>', 'Filter by status')
116
+ .option('--mine', 'Only jobs created by current agent')
117
+ .option('--json', 'JSON output')
118
+ .action(async (opts: any) => {
119
+ const list = await backend.listJobs({
120
+ tag: opts.tag,
121
+ status: opts.status,
122
+ mine: opts.mine ? agentId : undefined
123
+ });
124
+ if (opts.json) return output(list, true);
125
+ const table = renderTable(
126
+ list.map((job) => ({
127
+ id: job.id,
128
+ title: job.title,
129
+ mode: job.mode ?? 'SUBMISSION',
130
+ status: job.status,
131
+ reward: job.rewardAmount ?? job.reward,
132
+ closes: job.closesAt ?? job.expiresAt
133
+ })),
134
+ [
135
+ { key: 'id', label: 'ID' },
136
+ { key: 'title', label: 'Title' },
137
+ { key: 'mode', label: 'Mode' },
138
+ { key: 'status', label: 'Status' },
139
+ { key: 'reward', label: 'Reward', align: 'right' },
140
+ { key: 'closes', label: 'Closes' }
141
+ ]
142
+ );
143
+ console.log(table);
144
+ });
145
+
146
+ const submissions = consensus.command('submissions').description('Manage submissions');
147
+ submissions
148
+ .command('create <jobId>')
149
+ .description('Create a submission')
150
+ .requiredOption('--artifact <json>', 'Artifact JSON string')
151
+ .option('--summary <summary>', 'Submission summary')
152
+ .option('--confidence <n>', 'Confidence 0-1', parseFloat)
153
+ .option('--json', 'JSON output')
154
+ .action(async (jobId: string, opts: any) => {
155
+ const artifacts = JSON.parse(opts.artifact);
156
+ const submission = await backend.submitJob(agentId, jobId, {
157
+ summary: opts.summary ?? '',
158
+ artifacts,
159
+ confidence: Number(opts.confidence ?? 0.5)
160
+ });
161
+ output(submission, opts.json);
162
+ });
163
+
164
+ submissions
165
+ .command('list <jobId>')
166
+ .description('List submissions')
167
+ .option('--json', 'JSON output')
168
+ .action(async (jobId: string, opts: any) => {
169
+ const list = await backend.listSubmissions(jobId);
170
+ output(list, opts.json);
171
+ });
172
+
173
+ const votes = consensus.command('votes').description('Manage votes');
174
+ votes
175
+ .command('cast <jobId>')
176
+ .description('Cast a vote')
177
+ .option('--submission <id>', 'Submission id to vote for')
178
+ .option('--choice <key>', 'Choice key to vote for')
179
+ .option('--weight <n>', 'Vote weight', parseFloat)
180
+ .option('--json', 'JSON output')
181
+ .action(async (jobId: string, opts: any) => {
182
+ const vote = await backend.vote(agentId, jobId, {
183
+ submissionId: opts.submission,
184
+ choiceKey: opts.choice,
185
+ targetType: opts.submission ? 'SUBMISSION' : opts.choice ? 'CHOICE' : undefined,
186
+ targetId: opts.submission ?? opts.choice,
187
+ weight: opts.weight ?? 1,
188
+ score: opts.weight ?? 1
189
+ });
190
+ output(vote, opts.json);
191
+ });
192
+
193
+ votes
194
+ .command('list <jobId>')
195
+ .description('List votes')
196
+ .option('--json', 'JSON output')
197
+ .action(async (jobId: string, opts: any) => {
198
+ const list = await backend.listVotes(jobId);
199
+ output(list, opts.json);
200
+ });
201
+
202
+ consensus
203
+ .command('resolve <jobId>')
204
+ .description('Resolve a job')
205
+ .option('--winner <agentId>', 'Winner agent id (repeatable)', collect)
206
+ .option('--submission <submissionId>', 'Winning submission id')
207
+ .option('--json', 'JSON output')
208
+ .action(async (jobId: string, opts: any) => {
209
+ const resolution = await backend.resolveJob(agentId, jobId, {
210
+ manualWinners: opts.winner,
211
+ manualSubmissionId: opts.submission
212
+ });
213
+ output(resolution, opts.json);
214
+ });
215
+
216
+ const result = consensus.command('result').description('Read job result');
217
+ result
218
+ .command('get <jobId>')
219
+ .description('Get job result')
220
+ .option('--json', 'JSON output')
221
+ .action(async (jobId: string, opts: any) => {
222
+ const status = backend.getStatus ? await backend.getStatus(jobId) : await backend.getJob(jobId);
223
+ output(status, opts.json);
224
+ });
225
+
226
+ const tests = consensus.command('tests').description('Run consensus policy tests');
227
+ tests
228
+ .command('run')
229
+ .description('Run consensus policy tests with generation script')
230
+ .option('--agents <n>', 'Number of agent personalities', parseInt)
231
+ .option('--script <path>', 'Path to generation script', 'tests/runner/generation.ts')
232
+ .option('--openai-key <key>', 'OpenAI API key (or set OPENAI_API_KEY)')
233
+ .option('--model <name>', 'Model name', 'gpt-5.2')
234
+ .action(async (opts: any) => {
235
+ const apiKey = opts.openaiKey || process.env.OPENAI_API_KEY;
236
+ const agentCount = Number(opts.agents || 3);
237
+ const result = await runConsensusPolicyTests({
238
+ scriptPath: opts.script,
239
+ agentCount,
240
+ apiKey: apiKey || undefined,
241
+ model: opts.model
242
+ });
243
+ output(result, true);
244
+ });
245
+ }
246
+
247
+ function output(data: any, json: boolean) {
248
+ if (json) {
249
+ console.log(JSON.stringify(data, null, 2));
250
+ return;
251
+ }
252
+ if (typeof data === 'string') {
253
+ console.log(data);
254
+ return;
255
+ }
256
+ console.log(JSON.stringify(data, null, 2));
257
+ }
258
+
259
+ function collect(value: string, previous: string[] = []): string[] {
260
+ return previous.concat([value]);
261
+ }
262
+
263
+ async function readStdinIfAny(): Promise<string | undefined> {
264
+ if (process.stdin.isTTY) return undefined;
265
+ const chunks: Buffer[] = [];
266
+ for await (const chunk of process.stdin) {
267
+ chunks.push(Buffer.from(chunk));
268
+ }
269
+ const text = Buffer.concat(chunks).toString('utf8').trim();
270
+ return text || undefined;
271
+ }
272
+
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);
335
+ }
336
+
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> {
356
+ const baseDir = path.join(rootDir, '.consensus');
357
+ const apiDir = path.join(baseDir, 'api');
358
+
359
+ await fs.mkdir(apiDir, { recursive: true });
360
+
361
+ const files: Array<{ path: string; content: string; executable?: boolean }> = [
362
+ { 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) },
365
+ { path: path.join(apiDir, 'common.sh'), content: commonSh(), executable: true },
366
+ { path: path.join(apiDir, 'jobs_post.sh'), content: jobsPostSh(), executable: true },
367
+ { path: path.join(apiDir, 'jobs_get.sh'), content: jobsGetSh(), executable: true },
368
+ { path: path.join(apiDir, 'jobs_list.sh'), content: jobsListSh(), executable: true },
369
+ { path: path.join(apiDir, 'submissions_create.sh'), content: submissionsCreateSh(), executable: true },
370
+ { path: path.join(apiDir, 'submissions_list.sh'), content: submissionsListSh(), executable: true },
371
+ { path: path.join(apiDir, 'votes_cast.sh'), content: votesCastSh(), executable: true },
372
+ { path: path.join(apiDir, 'votes_list.sh'), content: votesListSh(), executable: true },
373
+ { path: path.join(apiDir, 'resolve.sh'), content: resolveSh(), executable: true },
374
+ { path: path.join(apiDir, 'result_get.sh'), content: resultGetSh(), executable: true }
375
+ ];
376
+
377
+ for (const file of files) {
378
+ await writeFile(file.path, file.content, force);
379
+ if (file.executable) {
380
+ await fs.chmod(file.path, 0o755);
381
+ }
382
+ }
383
+ }
384
+
385
+ async function writeFile(filePath: string, content: string, force: boolean): Promise<void> {
386
+ try {
387
+ await fs.access(filePath);
388
+ if (!force) return;
389
+ } catch {
390
+ // not found
391
+ }
392
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
393
+ await fs.writeFile(filePath, content, 'utf8');
394
+ }
395
+
396
+ function consensusReadme(): string {
397
+ return [
398
+ '# consensus.tools shell templates',
399
+ '',
400
+ 'This folder is generated by `consensus init`.',
401
+ '',
402
+ '## Quick start',
403
+ '',
404
+ '1) Copy and edit env vars:',
405
+ '',
406
+ '```bash',
407
+ 'cp .consensus/env.example .consensus/.env',
408
+ '# edit .consensus/.env',
409
+ 'source .consensus/.env',
410
+ '```',
411
+ '',
412
+ 'Try local mode:',
413
+ '',
414
+ '```bash',
415
+ 'export CONSENSUS_MODE=local',
416
+ 'bash .consensus/api/jobs_post.sh "Test job" "desc" "hello world"',
417
+ '```',
418
+ '',
419
+ 'Switch to remote mode:',
420
+ '',
421
+ '```bash',
422
+ 'export CONSENSUS_MODE=remote',
423
+ 'export CONSENSUS_URL="https://api.consensus.tools"',
424
+ 'export CONSENSUS_BOARD_ID="board_..."',
425
+ 'export CONSENSUS_API_KEY="..."',
426
+ 'bash .consensus/api/jobs_post.sh "Title" "Desc" "Input"',
427
+ '```',
428
+ '',
429
+ 'Notes',
430
+ '',
431
+ 'Local mode writes to CONSENSUS_ROOT (defaults in env.example).',
432
+ '',
433
+ 'Remote mode hits ${CONSENSUS_URL}/v1/boards/${CONSENSUS_BOARD_ID}/...',
434
+ '',
435
+ 'These scripts are intentionally readable and easy to customize.',
436
+ ''
437
+ ].join('\n');
438
+ }
439
+
440
+ function envExample(): string {
441
+ return [
442
+ '# Mode: "local" or "remote"',
443
+ 'export CONSENSUS_MODE=local',
444
+ '',
445
+ '# Local board root (JSON filesystem board)',
446
+ 'export CONSENSUS_ROOT="$HOME/.openclaw/workplace/consensus-board"',
447
+ '',
448
+ '# 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"',
452
+ '',
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"',
458
+ ''
459
+ ].join('\n');
460
+ }
461
+
462
+ function commonSh(): string {
463
+ return [
464
+ '#!/usr/bin/env bash',
465
+ 'set -euo pipefail',
466
+ '',
467
+ '# --------- helpers ----------',
468
+ 'now_iso() { date -Iseconds; }',
469
+ '',
470
+ 'require_env() {',
471
+ ' local name="$1"',
472
+ ' if [[ -z "${!name:-}" ]]; then',
473
+ ' echo "Missing required env var: $name" >&2',
474
+ ' exit 2',
475
+ ' fi',
476
+ '}',
477
+ '',
478
+ 'mode() { echo "${CONSENSUS_MODE:-local}"; }',
479
+ '',
480
+ 'local_root() {',
481
+ ' require_env "CONSENSUS_ROOT"',
482
+ ' echo "$CONSENSUS_ROOT"',
483
+ '}',
484
+ '',
485
+ 'ensure_local_board() {',
486
+ ' local root; root="$(local_root)"',
487
+ ' mkdir -p "$root/jobs"',
488
+ ' [[ -f "$root/ledger.json" ]] || echo "[]" > "$root/ledger.json"',
489
+ '}',
490
+ '',
491
+ 'rand_id() {',
492
+ ' # readable ids; good enough for local / scripting',
493
+ ' echo "${1}_$(date +%s)_$RANDOM"',
494
+ '}',
495
+ '',
496
+ 'json_escape() {',
497
+ ' # Safely JSON-escape an arbitrary string.',
498
+ ' # Requires python3 (common on dev machines).',
499
+ ' python3 - <<\'PY\' "$1"',
500
+ 'import json,sys',
501
+ 'print(json.dumps(sys.argv[1]))',
502
+ 'PY',
503
+ '}',
504
+ '',
505
+ '# --------- remote request ----------',
506
+ 'remote_base() {',
507
+ ' require_env "CONSENSUS_URL"',
508
+ ' require_env "CONSENSUS_BOARD_ID"',
509
+ ' echo "${CONSENSUS_URL%/}/v1/boards/${CONSENSUS_BOARD_ID}"',
510
+ '}',
511
+ '',
512
+ 'remote_auth_header() {',
513
+ ' require_env "CONSENSUS_API_KEY"',
514
+ ' echo "Authorization: Bearer ${CONSENSUS_API_KEY}"',
515
+ '}',
516
+ '',
517
+ 'curl_json() {',
518
+ ' # curl_json METHOD URL JSON_BODY',
519
+ ' local method="$1"',
520
+ ' local url="$2"',
521
+ ' local body="$3"',
522
+ '',
523
+ ' curl -sS -X "$method" "$url" \\',
524
+ ' -H "$(remote_auth_header)" \\',
525
+ ' -H "Content-Type: application/json" \\',
526
+ ' -d "$body"',
527
+ '}',
528
+ '',
529
+ '# --------- local IO ----------',
530
+ 'job_file() {',
531
+ ' local root; root="$(local_root)"',
532
+ ' echo "$root/jobs/${1}.json"',
533
+ '}',
534
+ '',
535
+ 'job_dir() {',
536
+ ' local root; root="$(local_root)"',
537
+ ' echo "$root/jobs/${1}"',
538
+ '}',
539
+ '',
540
+ 'ensure_job_dir() {',
541
+ ' local d; d="$(job_dir "$1")"',
542
+ ' mkdir -p "$d/submissions" "$d/votes"',
543
+ '}',
544
+ '',
545
+ 'write_json_file() {',
546
+ ' local path="$1"',
547
+ ' local contents="$2"',
548
+ ' mkdir -p "$(dirname "$path")"',
549
+ ' printf "%s\\n" "$contents" > "$path"',
550
+ '}',
551
+ '',
552
+ 'read_json_file() {',
553
+ ' local path="$1"',
554
+ ' if [[ ! -f "$path" ]]; then',
555
+ ' echo "Not found: $path" >&2',
556
+ ' exit 1',
557
+ ' fi',
558
+ ' cat "$path"',
559
+ '}',
560
+ ''
561
+ ].join('\n');
562
+ }
563
+
564
+ function jobsPostSh(): string {
565
+ return [
566
+ '#!/usr/bin/env bash',
567
+ 'set -euo pipefail',
568
+ 'source "$(dirname "$0")/common.sh"',
569
+ '',
570
+ 'TITLE="${1:-}"',
571
+ 'DESC="${2:-}"',
572
+ 'INPUT="${3:-}"',
573
+ '',
574
+ 'if [[ -z "$TITLE" ]]; then',
575
+ ' echo "Usage: jobs_post.sh <title> [desc] [input]" >&2',
576
+ ' exit 2',
577
+ 'fi',
578
+ '',
579
+ 'POLICY="${CONSENSUS_DEFAULT_POLICY:-HIGHEST_CONFIDENCE_SINGLE}"',
580
+ 'REWARD="${CONSENSUS_DEFAULT_REWARD:-8}"',
581
+ 'STAKE="${CONSENSUS_DEFAULT_STAKE:-4}"',
582
+ 'LEASE_SECONDS="${CONSENSUS_DEFAULT_LEASE_SECONDS:-180}"',
583
+ 'MODE="$(mode)"',
584
+ '',
585
+ 'if [[ "$MODE" == "local" ]]; then',
586
+ ' ensure_local_board',
587
+ ' local id; id="$(rand_id "job")"',
588
+ '',
589
+ ' local title_json desc_json input_json',
590
+ ' title_json="$(json_escape "$TITLE")"',
591
+ ' desc_json="$(json_escape "${DESC:-}")"',
592
+ ' input_json="$(json_escape "${INPUT:-}")"',
593
+ '',
594
+ ' local job_json',
595
+ ' job_json="$(cat <<JSON',
596
+ '{',
597
+ ' "id": "$id",',
598
+ ' "title": $title_json,',
599
+ ' "desc": $desc_json,',
600
+ ' "input": $input_json,',
601
+ ' "mode": "SUBMISSION",',
602
+ ' "policyKey": "$POLICY",',
603
+ ' "rewardAmount": $REWARD,',
604
+ ' "stakeAmount": $STAKE,',
605
+ ' "leaseSeconds": $LEASE_SECONDS,',
606
+ ' "status": "OPEN",',
607
+ ' "createdAt": "$(now_iso)"',
608
+ '}',
609
+ 'JSON',
610
+ ')"',
611
+ ' write_json_file "$(job_file "$id")" "$job_json"',
612
+ ' ensure_job_dir "$id"',
613
+ ' echo "$job_json"',
614
+ ' exit 0',
615
+ 'fi',
616
+ '',
617
+ '# remote',
618
+ 'base="$(remote_base)"',
619
+ 'payload="$(cat <<JSON',
620
+ '{',
621
+ ' "title": "$TITLE",',
622
+ ' "desc": "${DESC:-}",',
623
+ ' "input": "${INPUT:-}",',
624
+ ' "mode": "SUBMISSION",',
625
+ ' "policyKey": "$POLICY",',
626
+ ' "rewardAmount": $REWARD,',
627
+ ' "stakeAmount": $STAKE,',
628
+ ' "leaseSeconds": $LEASE_SECONDS',
629
+ '}',
630
+ 'JSON',
631
+ ')"',
632
+ 'curl_json "POST" "$base/jobs" "$payload"',
633
+ 'echo',
634
+ ''
635
+ ].join('\n');
636
+ }
637
+
638
+ function jobsGetSh(): string {
639
+ return [
640
+ '#!/usr/bin/env bash',
641
+ 'set -euo pipefail',
642
+ 'source "$(dirname "$0")/common.sh"',
643
+ '',
644
+ 'JOB_ID="${1:-}"',
645
+ 'if [[ -z "$JOB_ID" ]]; then',
646
+ ' echo "Usage: jobs_get.sh <jobId>" >&2',
647
+ ' exit 2',
648
+ 'fi',
649
+ '',
650
+ 'MODE="$(mode)"',
651
+ '',
652
+ 'if [[ "$MODE" == "local" ]]; then',
653
+ ' ensure_local_board',
654
+ ' read_json_file "$(job_file "$JOB_ID")"',
655
+ ' exit 0',
656
+ 'fi',
657
+ '',
658
+ 'base="$(remote_base)"',
659
+ 'curl -sS "$base/jobs/$JOB_ID" -H "$(remote_auth_header)"',
660
+ 'echo',
661
+ ''
662
+ ].join('\n');
663
+ }
664
+
665
+ function jobsListSh(): string {
666
+ return [
667
+ '#!/usr/bin/env bash',
668
+ 'set -euo pipefail',
669
+ 'source "$(dirname "$0")/common.sh"',
670
+ '',
671
+ 'MODE="$(mode)"',
672
+ '',
673
+ 'if [[ "$MODE" == "local" ]]; then',
674
+ ' ensure_local_board',
675
+ ' root="$(local_root)"',
676
+ ' ls -1 "$root/jobs"/*.json 2>/dev/null | sed "s#.*/##" | sed "s#\\.json$##" || true',
677
+ ' exit 0',
678
+ 'fi',
679
+ '',
680
+ 'base="$(remote_base)"',
681
+ '# Optional: pass query string as $1, e.g. "status=OPEN&mode=SUBMISSION"',
682
+ 'QS="${1:-}"',
683
+ 'url="$base/jobs"',
684
+ 'if [[ -n "$QS" ]]; then',
685
+ ' url="$url?$QS"',
686
+ 'fi',
687
+ 'curl -sS "$url" -H "$(remote_auth_header)"',
688
+ 'echo',
689
+ ''
690
+ ].join('\n');
691
+ }
692
+
693
+ function submissionsCreateSh(): string {
694
+ return [
695
+ '#!/usr/bin/env bash',
696
+ 'set -euo pipefail',
697
+ 'source "$(dirname "$0")/common.sh"',
698
+ '',
699
+ 'JOB_ID="${1:-}"',
700
+ 'ARTIFACT_JSON="${2:-}"',
701
+ 'SUMMARY="${3:-}"',
702
+ '',
703
+ 'if [[ -z "$JOB_ID" || -z "$ARTIFACT_JSON" ]]; then',
704
+ ' echo "Usage: submissions_create.sh <jobId> <artifact_json> [summary]" >&2',
705
+ ' echo "Example: submissions_create.sh job_... {\\\"toxic\\\":false,\\\"confidence\\\":0.98,\\\"brief_reason\\\":\\\"...\\\"}" >&2',
706
+ ' exit 2',
707
+ 'fi',
708
+ '',
709
+ 'MODE="$(mode)"',
710
+ '',
711
+ 'if [[ "$MODE" == "local" ]]; then',
712
+ ' ensure_local_board',
713
+ ' ensure_job_dir "$JOB_ID"',
714
+ '',
715
+ ' sid="$(rand_id "sub")"',
716
+ ' summary_json="$(json_escape "${SUMMARY:-}")"',
717
+ ' sub_json="$(cat <<JSON',
718
+ '{',
719
+ ' "id": "$sid",',
720
+ ' "jobId": "$JOB_ID",',
721
+ ' "artifact": $ARTIFACT_JSON,',
722
+ ' "summary": $summary_json,',
723
+ ' "createdAt": "$(now_iso)",',
724
+ ' "status": "VALID"',
725
+ '}',
726
+ 'JSON',
727
+ ')"',
728
+ ' write_json_file "$(job_dir "$JOB_ID")/submissions/${sid}.json" "$sub_json"',
729
+ ' echo "$sub_json"',
730
+ ' exit 0',
731
+ 'fi',
732
+ '',
733
+ 'base="$(remote_base)"',
734
+ 'payload="$(cat <<JSON',
735
+ '{',
736
+ ' "artifact": $ARTIFACT_JSON,',
737
+ ' "summary": "${SUMMARY:-}"',
738
+ '}',
739
+ 'JSON',
740
+ ')"',
741
+ 'curl_json "POST" "$base/jobs/$JOB_ID/submissions" "$payload"',
742
+ 'echo',
743
+ ''
744
+ ].join('\n');
745
+ }
746
+
747
+ function submissionsListSh(): string {
748
+ return [
749
+ '#!/usr/bin/env bash',
750
+ 'set -euo pipefail',
751
+ 'source "$(dirname "$0")/common.sh"',
752
+ '',
753
+ 'JOB_ID="${1:-}"',
754
+ 'if [[ -z "$JOB_ID" ]]; then',
755
+ ' echo "Usage: submissions_list.sh <jobId>" >&2',
756
+ ' exit 2',
757
+ 'fi',
758
+ '',
759
+ 'MODE="$(mode)"',
760
+ '',
761
+ 'if [[ "$MODE" == "local" ]]; then',
762
+ ' ensure_local_board',
763
+ ' ensure_job_dir "$JOB_ID"',
764
+ ' ls -1 "$(job_dir "$JOB_ID")/submissions"/*.json 2>/dev/null | xargs -I{} cat "{}" || true',
765
+ ' exit 0',
766
+ 'fi',
767
+ '',
768
+ 'base="$(remote_base)"',
769
+ 'curl -sS "$base/jobs/$JOB_ID/submissions" -H "$(remote_auth_header)"',
770
+ 'echo',
771
+ ''
772
+ ].join('\n');
773
+ }
774
+
775
+ function votesCastSh(): string {
776
+ return [
777
+ '#!/usr/bin/env bash',
778
+ 'set -euo pipefail',
779
+ 'source "$(dirname "$0")/common.sh"',
780
+ '',
781
+ 'JOB_ID="${1:-}"',
782
+ 'TARGET_TYPE="${2:-}" # SUBMISSION or CHOICE',
783
+ 'TARGET_ID="${3:-}" # submission id or choice key',
784
+ 'WEIGHT="${4:-1}"',
785
+ '',
786
+ 'if [[ -z "$JOB_ID" || -z "$TARGET_TYPE" || -z "$TARGET_ID" ]]; then',
787
+ ' echo "Usage: votes_cast.sh <jobId> <targetType:SUBMISSION|CHOICE> <targetId> [weight]" >&2',
788
+ ' echo "Example: votes_cast.sh job_... SUBMISSION sub_... 1" >&2',
789
+ ' echo "Example: votes_cast.sh job_... CHOICE TOXIC_FALSE 1" >&2',
790
+ ' exit 2',
791
+ 'fi',
792
+ '',
793
+ 'MODE="$(mode)"',
794
+ '',
795
+ 'if [[ "$MODE" == "local" ]]; then',
796
+ ' ensure_local_board',
797
+ ' ensure_job_dir "$JOB_ID"',
798
+ '',
799
+ ' vid="$(rand_id "vote")"',
800
+ ' vote_json="$(cat <<JSON',
801
+ '{',
802
+ ' "id": "$vid",',
803
+ ' "jobId": "$JOB_ID",',
804
+ ' "targetType": "$TARGET_TYPE",',
805
+ ' "targetId": "$TARGET_ID",',
806
+ ' "weight": $WEIGHT,',
807
+ ' "createdAt": "$(now_iso)"',
808
+ '}',
809
+ 'JSON',
810
+ ')"',
811
+ ' write_json_file "$(job_dir "$JOB_ID")/votes/${vid}.json" "$vote_json"',
812
+ ' echo "$vote_json"',
813
+ ' exit 0',
814
+ 'fi',
815
+ '',
816
+ 'base="$(remote_base)"',
817
+ 'payload="$(cat <<JSON',
818
+ '{',
819
+ ' "targetType": "$TARGET_TYPE",',
820
+ ' "targetId": "$TARGET_ID",',
821
+ ' "weight": $WEIGHT',
822
+ '}',
823
+ 'JSON',
824
+ ')"',
825
+ 'curl_json "POST" "$base/jobs/$JOB_ID/votes" "$payload"',
826
+ 'echo',
827
+ ''
828
+ ].join('\n');
829
+ }
830
+
831
+ function votesListSh(): string {
832
+ return [
833
+ '#!/usr/bin/env bash',
834
+ 'set -euo pipefail',
835
+ 'source "$(dirname "$0")/common.sh"',
836
+ '',
837
+ 'JOB_ID="${1:-}"',
838
+ 'if [[ -z "$JOB_ID" ]]; then',
839
+ ' echo "Usage: votes_list.sh <jobId>" >&2',
840
+ ' exit 2',
841
+ 'fi',
842
+ '',
843
+ 'MODE="$(mode)"',
844
+ '',
845
+ 'if [[ "$MODE" == "local" ]]; then',
846
+ ' ensure_local_board',
847
+ ' ensure_job_dir "$JOB_ID"',
848
+ ' ls -1 "$(job_dir "$JOB_ID")/votes"/*.json 2>/dev/null | xargs -I{} cat "{}" || true',
849
+ ' exit 0',
850
+ 'fi',
851
+ '',
852
+ 'base="$(remote_base)"',
853
+ 'curl -sS "$base/jobs/$JOB_ID/votes" -H "$(remote_auth_header)"',
854
+ 'echo',
855
+ ''
856
+ ].join('\n');
857
+ }
858
+
859
+ function resolveSh(): string {
860
+ return [
861
+ '#!/usr/bin/env bash',
862
+ 'set -euo pipefail',
863
+ 'source "$(dirname "$0")/common.sh"',
864
+ '',
865
+ 'JOB_ID="${1:-}"',
866
+ 'if [[ -z "$JOB_ID" ]]; then',
867
+ ' echo "Usage: resolve.sh <jobId>" >&2',
868
+ ' exit 2',
869
+ 'fi',
870
+ '',
871
+ 'MODE="$(mode)"',
872
+ '',
873
+ 'if [[ "$MODE" == "local" ]]; then',
874
+ ' ensure_local_board',
875
+ ' ensure_job_dir "$JOB_ID"',
876
+ '',
877
+ ' # Local resolution policy: HIGHEST_CONFIDENCE_SINGLE for SUBMISSION jobs.',
878
+ ' # We pick the submission with max artifact.confidence if present.',
879
+ ' # If missing, we fall back to the most recent submission.',
880
+ '',
881
+ ' dir="$(job_dir "$JOB_ID")/submissions"',
882
+ ' if ! ls "$dir"/*.json >/dev/null 2>&1; then',
883
+ ' echo "No submissions found for $JOB_ID" >&2',
884
+ ' exit 1',
885
+ ' fi',
886
+ '',
887
+ ' python3 - <<\'PY\' "$JOB_ID" "$dir" | tee "$(job_dir "$JOB_ID")/result.json"',
888
+ 'import json,glob,sys,os',
889
+ 'job_id=sys.argv[1]; d=sys.argv[2]',
890
+ 'subs=[]',
891
+ 'for p in glob.glob(os.path.join(d,"*.json")):',
892
+ ' with open(p,"r") as f:',
893
+ ' s=json.load(f)',
894
+ ' conf=None',
895
+ ' try:',
896
+ ' conf=float(s.get("artifact",{}).get("confidence"))',
897
+ ' except Exception:',
898
+ ' conf=None',
899
+ ' subs.append((conf,s.get("createdAt",""),s,p))',
900
+ '# sort: confidence desc (None last), then createdAt desc',
901
+ 'def key(t):',
902
+ ' conf,created,_,_ = t',
903
+ ' return (conf is not None, conf if conf is not None else -1.0, created)',
904
+ 'subs_sorted=sorted(subs, key=key, reverse=True)',
905
+ 'conf,created,s,p=subs_sorted[0]',
906
+ 'result={',
907
+ ' "jobId": job_id,',
908
+ ' "mode": "SUBMISSION",',
909
+ ' "selectedSubmissionId": s.get("id"),',
910
+ ' "selectedSubmissionPath": p,',
911
+ ' "resolvedAt": __import__("datetime").datetime.utcnow().isoformat()+"Z",',
912
+ ' "artifact": s.get("artifact"),',
913
+ ' "summary": s.get("summary","")',
914
+ '}',
915
+ 'print(json.dumps(result, indent=2))',
916
+ 'PY',
917
+ '',
918
+ ' exit 0',
919
+ 'fi',
920
+ '',
921
+ 'base="$(remote_base)"',
922
+ 'curl -sS -X POST "$base/jobs/$JOB_ID/resolve" -H "$(remote_auth_header)"',
923
+ 'echo',
924
+ ''
925
+ ].join('\n');
926
+ }
927
+
928
+ function resultGetSh(): string {
929
+ return [
930
+ '#!/usr/bin/env bash',
931
+ 'set -euo pipefail',
932
+ 'source "$(dirname "$0")/common.sh"',
933
+ '',
934
+ 'JOB_ID="${1:-}"',
935
+ 'if [[ -z "$JOB_ID" ]]; then',
936
+ ' echo "Usage: result_get.sh <jobId>" >&2',
937
+ ' exit 2',
938
+ 'fi',
939
+ '',
940
+ 'MODE="$(mode)"',
941
+ '',
942
+ 'if [[ "$MODE" == "local" ]]; then',
943
+ ' ensure_local_board',
944
+ ' path="$(job_dir "$JOB_ID")/result.json"',
945
+ ' read_json_file "$path"',
946
+ ' exit 0',
947
+ 'fi',
948
+ '',
949
+ 'base="$(remote_base)"',
950
+ 'curl -sS "$base/jobs/$JOB_ID/result" -H "$(remote_auth_header)"',
951
+ 'echo',
952
+ ''
953
+ ].join('\n');
954
+ }