@consensus-tools/consensus-tools 0.1.0 → 0.1.3

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.
@@ -0,0 +1,97 @@
1
+ import { existsSync, promises as fs } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ export type ConsensusCliConfig = {
6
+ agentId?: string;
7
+ activeBoard: 'local' | 'remote';
8
+ boards: {
9
+ local: { type: 'local'; root: string; jobsPath: string; ledgerPath: string };
10
+ remote: { type: 'remote'; url: string; boardId: string; auth: { type: 'apiKey'; apiKeyEnv: string } };
11
+ };
12
+ defaults: { policy: string; reward: number; stake: number; leaseSeconds: number };
13
+ };
14
+
15
+ export const defaultConsensusCliConfig: ConsensusCliConfig = {
16
+ activeBoard: 'remote',
17
+ boards: {
18
+ local: {
19
+ type: 'local',
20
+ root: '~/.openclaw/workplace/consensus-board',
21
+ jobsPath: 'jobs',
22
+ ledgerPath: 'ledger.json'
23
+ },
24
+ remote: {
25
+ type: 'remote',
26
+ url: 'https://api.consensus.tools',
27
+ boardId: 'board_all',
28
+ auth: { type: 'apiKey', apiKeyEnv: 'CONSENSUS_API_KEY' }
29
+ }
30
+ },
31
+ defaults: {
32
+ policy: 'HIGHEST_CONFIDENCE_SINGLE',
33
+ reward: 8,
34
+ stake: 4,
35
+ leaseSeconds: 180
36
+ }
37
+ };
38
+
39
+ export function expandHome(input: string): string {
40
+ if (!input.startsWith('~')) return input;
41
+ return path.join(os.homedir(), input.slice(1));
42
+ }
43
+
44
+ export function resolveCliConfigPath(cwd: string = process.cwd()): string {
45
+ const envPath = process.env.CONSENSUS_CONFIG;
46
+ if (envPath) return expandHome(envPath);
47
+
48
+ const local = path.join(cwd, '.consensus', 'config.json');
49
+ if (existsSync(local)) return local;
50
+
51
+ return path.join(os.homedir(), '.consensus', 'config.json');
52
+ }
53
+
54
+ export async function loadCliConfig(cwd: string = process.cwd()): Promise<ConsensusCliConfig> {
55
+ const filePath = resolveCliConfigPath(cwd);
56
+ try {
57
+ const raw = await fs.readFile(filePath, 'utf8');
58
+ return JSON.parse(raw) as ConsensusCliConfig;
59
+ } catch {
60
+ return JSON.parse(JSON.stringify(defaultConsensusCliConfig)) as ConsensusCliConfig;
61
+ }
62
+ }
63
+
64
+ export async function saveCliConfig(config: ConsensusCliConfig, cwd: string = process.cwd()): Promise<void> {
65
+ const filePath = resolveCliConfigPath(cwd);
66
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
67
+ await fs.writeFile(filePath, JSON.stringify(config, null, 2), 'utf8');
68
+ }
69
+
70
+ export function getConfigValue(config: any, key: string): any {
71
+ return key.split('.').reduce((acc, part) => (acc ? acc[part] : undefined), config);
72
+ }
73
+
74
+ export function setConfigValue(config: any, key: string, value: any): void {
75
+ const parts = key.split('.');
76
+ let cur = config as any;
77
+ for (let i = 0; i < parts.length - 1; i += 1) {
78
+ if (!cur[parts[i]]) cur[parts[i]] = {};
79
+ cur = cur[parts[i]];
80
+ }
81
+ cur[parts[parts.length - 1]] = value;
82
+ }
83
+
84
+ export function parseValue(input: string): any {
85
+ try {
86
+ return JSON.parse(input);
87
+ } catch {
88
+ return input;
89
+ }
90
+ }
91
+
92
+ export function resolveRemoteBaseUrl(remoteUrl: string, boardId: string): string {
93
+ const trimmed = remoteUrl.replace(/\/$/, '');
94
+ if (trimmed.includes('/v1/boards/')) return trimmed;
95
+ return `${trimmed}/v1/boards/${boardId}`;
96
+ }
97
+
package/src/config.ts CHANGED
@@ -50,8 +50,9 @@ export const configSchema = {
50
50
  type: {
51
51
  type: 'string',
52
52
  enum: [
53
- 'SINGLE_WINNER',
53
+ 'FIRST_SUBMISSION_WINS',
54
54
  'HIGHEST_CONFIDENCE_SINGLE',
55
+ 'APPROVAL_VOTE',
55
56
  'OWNER_PICK',
56
57
  'TOP_K_SPLIT',
57
58
  'MAJORITY_VOTE',
@@ -59,7 +60,7 @@ export const configSchema = {
59
60
  'WEIGHTED_REPUTATION',
60
61
  'TRUSTED_ARBITER'
61
62
  ],
62
- default: 'SINGLE_WINNER'
63
+ default: 'FIRST_SUBMISSION_WINS'
63
64
  },
64
65
  trustedArbiterAgentId: { type: 'string', default: '' },
65
66
  minConfidence: { type: 'number', default: 0, minimum: 0, maximum: 1 },
@@ -99,8 +100,9 @@ export const configSchema = {
99
100
  type: {
100
101
  type: 'string',
101
102
  enum: [
102
- 'SINGLE_WINNER',
103
+ 'FIRST_SUBMISSION_WINS',
103
104
  'HIGHEST_CONFIDENCE_SINGLE',
105
+ 'APPROVAL_VOTE',
104
106
  'OWNER_PICK',
105
107
  'TOP_K_SPLIT',
106
108
  'MAJORITY_VOTE',
@@ -190,12 +192,13 @@ export const defaultConfig: ConsensusToolsConfig = {
190
192
  maxParticipants: 3,
191
193
  minParticipants: 1,
192
194
  expiresSeconds: 86400,
193
- consensusPolicy: { type: 'SINGLE_WINNER', trustedArbiterAgentId: '' },
195
+ consensusPolicy: { type: 'FIRST_SUBMISSION_WINS', trustedArbiterAgentId: '', tieBreak: 'earliest' },
194
196
  slashingPolicy: { enabled: false, slashPercent: 0, slashFlat: 0 }
195
197
  },
196
198
  consensusPolicies: {
197
- SINGLE_WINNER: { type: 'SINGLE_WINNER' },
199
+ FIRST_SUBMISSION_WINS: { type: 'FIRST_SUBMISSION_WINS' },
198
200
  HIGHEST_CONFIDENCE_SINGLE: { type: 'HIGHEST_CONFIDENCE_SINGLE', minConfidence: 0 },
201
+ APPROVAL_VOTE: { type: 'APPROVAL_VOTE', quorum: 1, minScore: 1, minMargin: 0, tieBreak: 'earliest', approvalVote: { weightMode: 'equal', settlement: 'immediate' } },
199
202
  OWNER_PICK: { type: 'OWNER_PICK' },
200
203
  TOP_K_SPLIT: { type: 'TOP_K_SPLIT', topK: 2, ordering: 'confidence' },
201
204
  MAJORITY_VOTE: { type: 'MAJORITY_VOTE' },
@@ -0,0 +1,237 @@
1
+ import os from 'node:os';
2
+ import { createInterface, type Interface } from 'node:readline/promises';
3
+ import process from 'node:process';
4
+ import type { ConsensusCliConfig } from './cliConfig';
5
+ import { defaultConsensusCliConfig } from './cliConfig';
6
+
7
+ export type InitWizardResult = {
8
+ config: ConsensusCliConfig;
9
+ // Values are already shell-escaped for `export KEY=<value>` lines.
10
+ env?: Record<string, string>;
11
+ };
12
+
13
+ const POLICY_CHOICES = [
14
+ 'HIGHEST_CONFIDENCE_SINGLE',
15
+ 'APPROVAL_VOTE',
16
+ 'TOP_K_SPLIT',
17
+ 'OWNER_PICK',
18
+ 'FIRST_SUBMISSION_WINS',
19
+ 'MAJORITY_VOTE',
20
+ 'WEIGHTED_VOTE_SIMPLE',
21
+ 'WEIGHTED_REPUTATION',
22
+ 'TRUSTED_ARBITER'
23
+ ] as const;
24
+
25
+ function defaultAgentId(): string {
26
+ const fromEnv = process.env.CONSENSUS_AGENT_ID;
27
+ if (fromEnv) return fromEnv;
28
+ let user = 'cli';
29
+ try {
30
+ const u = os.userInfo();
31
+ if (u?.username) user = u.username;
32
+ } catch {
33
+ // ignore
34
+ }
35
+ return `${user}@${os.hostname()}`;
36
+ }
37
+
38
+ function shellEscape(value: string): string {
39
+ // Conservative quoting for bash/zsh.
40
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`')}"`;
41
+ }
42
+
43
+ function toNumber(value: string, fallback: number): number {
44
+ const n = Number(value);
45
+ return Number.isFinite(n) ? n : fallback;
46
+ }
47
+
48
+ function toInt(value: string, fallback: number): number {
49
+ const n = Number.parseInt(value, 10);
50
+ return Number.isFinite(n) ? n : fallback;
51
+ }
52
+
53
+ async function promptLine(rl: Interface, message: string, fallback?: string): Promise<string> {
54
+ const suffix = fallback ? ` [${fallback}]` : '';
55
+ const answer = (await rl.question(`${message}${suffix}: `)).trim();
56
+ return answer || fallback || '';
57
+ }
58
+
59
+ async function promptConfirm(rl: Interface, message: string, fallback: boolean): Promise<boolean> {
60
+ const hint = fallback ? 'Y/n' : 'y/N';
61
+ const answer = (await rl.question(`${message} (${hint}): `)).trim().toLowerCase();
62
+ if (!answer) return fallback;
63
+ if (['y', 'yes'].includes(answer)) return true;
64
+ if (['n', 'no'].includes(answer)) return false;
65
+ return fallback;
66
+ }
67
+
68
+ async function promptSelect<T extends string>(
69
+ rl: Interface,
70
+ message: string,
71
+ choices: Array<{ value: T; label: string }>,
72
+ fallback: T
73
+ ): Promise<T> {
74
+ process.stdout.write(`${message}\n`);
75
+ for (let i = 0; i < choices.length; i += 1) {
76
+ const c = choices[i];
77
+ process.stdout.write(` ${i + 1}) ${c.label}\n`);
78
+ }
79
+ const answer = (await rl.question(`Select [${choices.findIndex((c) => c.value === fallback) + 1}]: `)).trim();
80
+ if (!answer) return fallback;
81
+ const idx = Number.parseInt(answer, 10);
82
+ if (Number.isFinite(idx) && idx >= 1 && idx <= choices.length) {
83
+ return choices[idx - 1].value;
84
+ }
85
+ // Allow direct value entry.
86
+ const asValue = answer as T;
87
+ if (choices.some((c) => c.value === asValue)) return asValue;
88
+ return fallback;
89
+ }
90
+
91
+ async function promptPassword(message: string): Promise<string> {
92
+ // Minimal masked input for TTY use. Falls back to visible input if raw mode is unavailable.
93
+ if (!process.stdin.isTTY || !process.stdout.isTTY || typeof process.stdin.setRawMode !== 'function') {
94
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
95
+ try {
96
+ return (await rl.question(`${message} (input will be visible): `)).trim();
97
+ } finally {
98
+ rl.close();
99
+ }
100
+ }
101
+
102
+ return await new Promise<string>((resolve) => {
103
+ const stdin = process.stdin;
104
+ const stdout = process.stdout;
105
+ let value = '';
106
+
107
+ stdout.write(`${message}: `);
108
+ stdin.setRawMode(true);
109
+ stdin.resume();
110
+
111
+ const cleanup = () => {
112
+ stdin.setRawMode(false);
113
+ stdin.pause();
114
+ stdin.removeListener('data', onData);
115
+ stdout.write('\n');
116
+ };
117
+
118
+ const onData = (buf: Buffer) => {
119
+ const s = buf.toString('utf8');
120
+ if (s === '\r' || s === '\n') {
121
+ cleanup();
122
+ resolve(value);
123
+ return;
124
+ }
125
+ if (s === '\u0003') {
126
+ // Ctrl-C
127
+ cleanup();
128
+ process.exit(130);
129
+ }
130
+ if (s === '\u007f') {
131
+ // backspace
132
+ if (value.length > 0) {
133
+ value = value.slice(0, -1);
134
+ stdout.write('\b \b');
135
+ }
136
+ return;
137
+ }
138
+ // Ignore arrow keys / escape sequences.
139
+ if (s.startsWith('\u001b')) return;
140
+ value += s;
141
+ stdout.write('*');
142
+ };
143
+
144
+ stdin.on('data', onData);
145
+ });
146
+ }
147
+
148
+ export async function runInitWizard(rootDir: string): Promise<InitWizardResult> {
149
+ process.stdout.write(
150
+ [
151
+ '+---------------------------------+',
152
+ '| consensus-tools init wizard |',
153
+ '+---------------------------------+',
154
+ `workspace: ${rootDir}`,
155
+ ''
156
+ ].join('\n') + '\n'
157
+ );
158
+
159
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
160
+ try {
161
+ const mode = await promptSelect<'remote' | 'local'>(
162
+ rl,
163
+ 'Where should consensus-tools run?',
164
+ [
165
+ { value: 'remote', label: 'Hosted board (Recommended)' },
166
+ { value: 'local', label: 'Local files (shell scripts; limited)' }
167
+ ],
168
+ 'remote'
169
+ );
170
+
171
+ const cfg: ConsensusCliConfig = JSON.parse(JSON.stringify(defaultConsensusCliConfig));
172
+ cfg.activeBoard = mode;
173
+
174
+ cfg.agentId = await promptLine(rl, 'Default agent id (sent as agentId)', cfg.agentId || defaultAgentId());
175
+
176
+ cfg.defaults.policy = await promptSelect<string>(
177
+ rl,
178
+ 'Default consensus policy',
179
+ POLICY_CHOICES.map((p) => ({ value: p, label: p })),
180
+ cfg.defaults.policy
181
+ );
182
+
183
+ cfg.defaults.reward = toNumber(await promptLine(rl, 'Default reward (credits)', String(cfg.defaults.reward)), cfg.defaults.reward);
184
+ cfg.defaults.stake = toNumber(await promptLine(rl, 'Default stake (credits)', String(cfg.defaults.stake)), cfg.defaults.stake);
185
+ cfg.defaults.leaseSeconds = toInt(
186
+ await promptLine(rl, 'Default leaseSeconds', String(cfg.defaults.leaseSeconds)),
187
+ cfg.defaults.leaseSeconds
188
+ );
189
+
190
+ if (mode === 'remote') {
191
+ cfg.boards.remote.url = await promptLine(rl, 'Hosted board URL (no trailing /v1/boards)', cfg.boards.remote.url);
192
+ cfg.boards.remote.boardId = await promptLine(rl, 'Board id', cfg.boards.remote.boardId);
193
+ cfg.boards.remote.auth.apiKeyEnv = await promptLine(
194
+ rl,
195
+ 'Env var name for access token',
196
+ cfg.boards.remote.auth.apiKeyEnv
197
+ );
198
+
199
+ const writeEnv = await promptConfirm(rl, 'Write .consensus/.env now? (do not commit it)', false);
200
+ if (!writeEnv) return { config: cfg };
201
+
202
+ const token = (await promptPassword(`Access token value (${cfg.boards.remote.auth.apiKeyEnv})`)).trim();
203
+ const env = buildEnv(cfg, token);
204
+ return { config: cfg, env };
205
+ }
206
+
207
+ cfg.boards.local.root = await promptLine(rl, 'Local board root (used by generated shell scripts)', cfg.boards.local.root);
208
+ return { config: cfg };
209
+ } finally {
210
+ rl.close();
211
+ }
212
+ }
213
+
214
+ function buildEnv(cfg: ConsensusCliConfig, token: string): Record<string, string> {
215
+ const env: Record<string, string> = {};
216
+ env.CONSENSUS_MODE = cfg.activeBoard === 'remote' ? 'remote' : 'local';
217
+ env.CONSENSUS_AGENT_ID = cfg.agentId || defaultAgentId();
218
+
219
+ env.CONSENSUS_DEFAULT_POLICY = cfg.defaults.policy;
220
+ env.CONSENSUS_DEFAULT_REWARD = String(cfg.defaults.reward);
221
+ env.CONSENSUS_DEFAULT_STAKE = String(cfg.defaults.stake);
222
+ env.CONSENSUS_DEFAULT_LEASE_SECONDS = String(cfg.defaults.leaseSeconds);
223
+
224
+ if (cfg.activeBoard === 'remote') {
225
+ env.CONSENSUS_URL = cfg.boards.remote.url;
226
+ env.CONSENSUS_BOARD_ID = cfg.boards.remote.boardId;
227
+ env.CONSENSUS_API_KEY_ENV = cfg.boards.remote.auth.apiKeyEnv || 'CONSENSUS_API_KEY';
228
+ env[env.CONSENSUS_API_KEY_ENV] = token;
229
+ } else {
230
+ env.CONSENSUS_ROOT = cfg.boards.local.root;
231
+ }
232
+
233
+ // Return a map with already-escaped values for direct file emission.
234
+ const escaped: Record<string, string> = {};
235
+ for (const [k, v] of Object.entries(env)) escaped[k] = shellEscape(v);
236
+ return escaped;
237
+ }
@@ -56,7 +56,7 @@ export function resolveConsensus(input: ConsensusInput): ConsensusResult {
56
56
  return { winners: [], winningSubmissionIds: [], consensusTrace: { policy, reason: 'no_submissions' }, finalArtifact: null };
57
57
  }
58
58
 
59
- if (policy === 'SINGLE_WINNER') {
59
+ if (policy === 'FIRST_SUBMISSION_WINS') {
60
60
  const sorted = [...input.submissions].sort((a, b) => Date.parse(a.submittedAt) - Date.parse(b.submittedAt));
61
61
  const winner = sorted[0];
62
62
  return {
@@ -67,6 +67,121 @@ export function resolveConsensus(input: ConsensusInput): ConsensusResult {
67
67
  };
68
68
  }
69
69
 
70
+ if (policy === 'APPROVAL_VOTE') {
71
+ const quorum = input.job.consensusPolicy.quorum;
72
+ const minScore = input.job.consensusPolicy.minScore ?? 1;
73
+ const minMargin = input.job.consensusPolicy.minMargin ?? 0;
74
+ const tieBreak = input.job.consensusPolicy.tieBreak ?? 'earliest';
75
+
76
+ const weightMode = input.job.consensusPolicy.approvalVote?.weightMode ?? 'equal';
77
+ const settlement = input.job.consensusPolicy.approvalVote?.settlement ?? 'immediate';
78
+
79
+ // Oracle settlement can always be manually finalized by the arbiter, even with no votes.
80
+ if (settlement === 'oracle' && input.manualWinnerAgentIds && input.manualWinnerAgentIds.length) {
81
+ return {
82
+ winners: input.manualWinnerAgentIds,
83
+ winningSubmissionIds: input.manualSubmissionId ? [input.manualSubmissionId] : [],
84
+ consensusTrace: { policy, settlement, mode: 'manual' },
85
+ finalArtifact: findArtifact(input, input.manualSubmissionId)
86
+ };
87
+ }
88
+
89
+ const scores: Record<string, number> = {};
90
+ const voteCounts: Record<string, number> = {};
91
+
92
+ // Only consider votes that target submissions.
93
+ const votes = input.votes.filter((v) => v.submissionId || (v.targetType === 'SUBMISSION' && v.targetId));
94
+ if (quorum && votes.length < quorum) {
95
+ return {
96
+ winners: [],
97
+ winningSubmissionIds: [],
98
+ consensusTrace: { policy, settlement, reason: 'quorum_not_met', quorum, votes: votes.length },
99
+ finalArtifact: null
100
+ };
101
+ }
102
+
103
+ for (const vote of votes) {
104
+ const sid = vote.submissionId ?? (vote.targetType === 'SUBMISSION' ? vote.targetId : undefined);
105
+ if (!sid) continue;
106
+
107
+ let weight = 1;
108
+ if (weightMode === 'explicit') weight = vote.weight ?? 1;
109
+ if (weightMode === 'reputation') weight = input.reputation(vote.agentId);
110
+
111
+ // score should be +1 (YES) or -1 (NO); clamp to [-1,1] to avoid weirdness.
112
+ const s = Math.max(-1, Math.min(1, vote.score ?? 0));
113
+ scores[sid] = (scores[sid] || 0) + s * weight;
114
+ voteCounts[sid] = (voteCounts[sid] || 0) + 1;
115
+ }
116
+
117
+ // rank submissions by score desc
118
+ const ranked = input.submissions
119
+ .map((sub) => ({ sub, score: scores[sub.id] || 0, votes: voteCounts[sub.id] || 0 }))
120
+ .sort((a, b) => {
121
+ if (b.score === a.score) {
122
+ if (tieBreak === 'confidence') return b.sub.confidence - a.sub.confidence;
123
+ // default earliest
124
+ return Date.parse(a.sub.submittedAt) - Date.parse(b.sub.submittedAt);
125
+ }
126
+ return b.score - a.score;
127
+ });
128
+
129
+ const best = ranked[0];
130
+ const second = ranked[1];
131
+ const margin = second ? best.score - second.score : best.score;
132
+
133
+ if (!best || best.votes === 0) {
134
+ return {
135
+ winners: [],
136
+ winningSubmissionIds: [],
137
+ consensusTrace: { policy, settlement, reason: 'no_votes', scores, voteCounts },
138
+ finalArtifact: null
139
+ };
140
+ }
141
+
142
+ if (best.score < minScore || margin < minMargin) {
143
+ return {
144
+ winners: [],
145
+ winningSubmissionIds: [],
146
+ consensusTrace: { policy, settlement, reason: 'threshold_not_met', minScore, minMargin, best: best.score, margin, scores, voteCounts },
147
+ finalArtifact: null
148
+ };
149
+ }
150
+
151
+ if (settlement === 'oracle' || tieBreak === 'arbiter') {
152
+ // Oracle / arbiter settlement: allow manual finalization, otherwise provide a recommendation.
153
+ if (input.manualWinnerAgentIds && input.manualWinnerAgentIds.length) {
154
+ return {
155
+ winners: input.manualWinnerAgentIds,
156
+ winningSubmissionIds: input.manualSubmissionId ? [input.manualSubmissionId] : [],
157
+ consensusTrace: {
158
+ policy,
159
+ settlement,
160
+ mode: 'manual',
161
+ recommendedSubmissionId: best.sub.id,
162
+ recommendedAgentId: best.sub.agentId,
163
+ scores,
164
+ voteCounts
165
+ },
166
+ finalArtifact: findArtifact(input, input.manualSubmissionId)
167
+ };
168
+ }
169
+ return {
170
+ winners: [],
171
+ winningSubmissionIds: [],
172
+ consensusTrace: { policy, settlement, mode: 'awaiting_oracle', recommendedSubmissionId: best.sub.id, recommendedAgentId: best.sub.agentId, scores, voteCounts },
173
+ finalArtifact: null
174
+ };
175
+ }
176
+
177
+ return {
178
+ winners: [best.sub.agentId],
179
+ winningSubmissionIds: [best.sub.id],
180
+ consensusTrace: { policy, settlement, scores, voteCounts, minScore, minMargin, tieBreak },
181
+ finalArtifact: best.sub.artifacts
182
+ };
183
+ }
184
+
70
185
  if (policy === 'HIGHEST_CONFIDENCE_SINGLE') {
71
186
  const minConfidence = input.job.consensusPolicy.minConfidence ?? 0;
72
187
  const sorted = [...input.submissions]
@@ -297,11 +297,13 @@ export class JobEngine {
297
297
  async vote(agentId: string, jobId: string, input: VoteInput): Promise<Vote> {
298
298
  const now = nowIso();
299
299
  return (await this.storage.update((state) => {
300
+ // optional: stake on votes (APPROVAL_VOTE staked settlement)
301
+ const stakeAmount = input.stakeAmount ? Math.max(0, Number(input.stakeAmount)) : 0;
300
302
  const job = state.jobs.find((j) => j.id === jobId);
301
303
  if (!job) throw new Error(`Job not found: ${jobId}`);
302
304
  if (
303
305
  job.mode === 'SUBMISSION' &&
304
- (job.consensusPolicy.type === 'SINGLE_WINNER' ||
306
+ (job.consensusPolicy.type === 'FIRST_SUBMISSION_WINS' ||
305
307
  job.consensusPolicy.type === 'HIGHEST_CONFIDENCE_SINGLE' ||
306
308
  job.consensusPolicy.type === 'OWNER_PICK' ||
307
309
  job.consensusPolicy.type === 'TOP_K_SPLIT' ||
@@ -309,6 +311,22 @@ export class JobEngine {
309
311
  ) {
310
312
  throw new Error('Voting not enabled for this job');
311
313
  }
314
+
315
+ // optional vote stake (only meaningful for APPROVAL_VOTE settlement=staked)
316
+ if (stakeAmount > 0) {
317
+ const currentBalance = getBalance(state.ledger, agentId);
318
+ const nextBalance = currentBalance - Math.abs(stakeAmount);
319
+ ensureNonNegative(nextBalance, `${agentId} vote stake for ${jobId}`);
320
+ state.ledger.push({
321
+ id: newId('ledger'),
322
+ at: now,
323
+ type: 'STAKE',
324
+ agentId,
325
+ amount: -Math.abs(stakeAmount),
326
+ jobId,
327
+ reason: 'vote'
328
+ });
329
+ }
312
330
  const targetType = input.targetType ?? (input.submissionId ? 'SUBMISSION' : input.choiceKey ? 'CHOICE' : undefined);
313
331
  const targetId = input.targetId ?? input.submissionId;
314
332
  if (targetType === 'SUBMISSION') {
@@ -330,7 +348,7 @@ export class JobEngine {
330
348
  agentId,
331
349
  score,
332
350
  weight: input.weight ?? score,
333
- stakeAmount: input.stakeAmount,
351
+ stakeAmount: stakeAmount || undefined,
334
352
  rationale: input.rationale,
335
353
  createdAt: now
336
354
  };
@@ -359,10 +377,28 @@ export class JobEngine {
359
377
  if (arbiter && arbiter !== agentId) {
360
378
  throw new Error('Only the trusted arbiter can resolve this job');
361
379
  }
380
+ if (!input.manualWinners || input.manualWinners.length === 0) {
381
+ throw new Error('Trusted arbiter must provide manual winners to resolve');
382
+ }
362
383
  }
363
384
 
364
- if (job.consensusPolicy.type === 'OWNER_PICK' && job.createdByAgentId !== agentId) {
365
- throw new Error('Only the job creator can resolve this job');
385
+ if (job.consensusPolicy.type === 'OWNER_PICK') {
386
+ if (job.createdByAgentId !== agentId) {
387
+ throw new Error('Only the job creator can resolve this job');
388
+ }
389
+ if (!input.manualWinners || input.manualWinners.length === 0) {
390
+ throw new Error('Owner must provide manual winners to resolve');
391
+ }
392
+ }
393
+
394
+ if (job.consensusPolicy.type === 'APPROVAL_VOTE' && job.consensusPolicy.approvalVote?.settlement === 'oracle') {
395
+ const arbiter = job.consensusPolicy.trustedArbiterAgentId;
396
+ if (arbiter && arbiter !== agentId) {
397
+ throw new Error('Only the trusted arbiter can resolve this job');
398
+ }
399
+ if (!input.manualWinners || input.manualWinners.length === 0) {
400
+ throw new Error('Oracle settlement requires manual winners to resolve');
401
+ }
366
402
  }
367
403
 
368
404
  const submissions = state.submissions.filter((s) => s.jobId === jobId);
@@ -398,6 +434,41 @@ export class JobEngine {
398
434
  }
399
435
  }
400
436
 
437
+ // Vote-stake settlement for APPROVAL_VOTE (best-effort, local-first)
438
+ if (job.consensusPolicy.type === 'APPROVAL_VOTE' && job.consensusPolicy.approvalVote?.settlement === 'staked') {
439
+ const winnerSubmissionId = consensus.winningSubmissionIds?.[0];
440
+ const voteSlashPercent = Math.max(0, Math.min(1, job.consensusPolicy.approvalVote?.voteSlashPercent ?? 0));
441
+ for (const v of votes) {
442
+ const st = v.stakeAmount ?? 0;
443
+ if (!st || st <= 0) continue;
444
+
445
+ // Return stake by default
446
+ state.ledger.push({
447
+ id: newId('ledger'),
448
+ at: now,
449
+ type: 'UNSTAKE',
450
+ agentId: v.agentId,
451
+ amount: st,
452
+ jobId,
453
+ reason: 'vote'
454
+ });
455
+
456
+ // Slash if vote is "wrong" relative to winner.
457
+ // Wrong = YES on non-winner OR NO on winner.
458
+ const votedSubmissionId = v.submissionId ?? (v.targetType === 'SUBMISSION' ? v.targetId : undefined);
459
+ const isYes = (v.score ?? 0) > 0;
460
+ const isNo = (v.score ?? 0) < 0;
461
+ const wrong =
462
+ (winnerSubmissionId && votedSubmissionId && votedSubmissionId !== winnerSubmissionId && isYes) ||
463
+ (winnerSubmissionId && votedSubmissionId && votedSubmissionId === winnerSubmissionId && isNo);
464
+
465
+ if (wrong && voteSlashPercent > 0) {
466
+ const slashAmount = Math.min(st, st * voteSlashPercent);
467
+ slashes.push({ agentId: v.agentId, amount: slashAmount, reason: 'vote_wrong' });
468
+ }
469
+ }
470
+ }
471
+
401
472
  const submissionAgents = new Set(submissions.map((s) => s.agentId));
402
473
  for (const bid of bids) {
403
474
  const stakeAmount = bid.stakeAmount;