@consensus-tools/consensus-tools 0.1.2 → 0.1.4

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,6 +1,6 @@
1
1
  # consensus.tools
2
2
 
3
- ![consensus-tools](./assets/consensus-tools)
3
+ ![consensus-tools](https://cdn.jsdelivr.net/npm/@consensus-tools/consensus-tools@latest/assets/consensus-tools.png)
4
4
 
5
5
  **High-confidence decisions for agentic systems.**
6
6
  Local-first. Incentive-aligned. Verifiable.
@@ -162,92 +162,86 @@ The CLI generates .sh API templates so everything is scriptable and inspectable.
162
162
 
163
163
  Policies define how a job resolves. The defaults below are built-in and fully auditable.
164
164
 
165
- ### Submission-mode policies
165
+ ### Active policy set (current)
166
166
 
167
- #### HIGHEST_CONFIDENCE_SINGLE
167
+ The currently documented/default policy set is:
168
168
 
169
- Pick the submission with the highest declared confidence (optionally requiring a minimum threshold).
169
+ - `FIRST_SUBMISSION_WINS`
170
+ - `HIGHEST_CONFIDENCE_SINGLE`
171
+ - `APPROVAL_VOTE`
172
+ - `OWNER_PICK`
173
+ - `TRUSTED_ARBITER`
170
174
 
171
- Best for: safety checks, moderation, “false positives not allowed”
175
+ ### Submission-oriented policies
172
176
 
173
- Why it works: encourages restraint and honesty over volume.
177
+ #### FIRST_SUBMISSION_WINS
174
178
 
175
- Reward split (v1):
179
+ The earliest valid submission wins.
176
180
 
177
- - 100% of the reward goes to the selected submission.
178
- - All other submissions receive 0%.
179
- - If no submission meets `minConfidence`, no reward is paid and funds return to the job creator (or remain unallocated, board-configurable).
181
+ Best for: speedrun tasks, low-ambiguity jobs, first-correct workflows.
180
182
 
181
- Optional (later): submission stake can be slashed for provably bad outputs.
183
+ Reward split (v1):
182
184
 
183
- #### OWNER_PICK
185
+ - 100% of reward to the first valid submission.
186
+ - Other submissions receive 0%.
184
187
 
185
- Anyone can submit; the job creator selects a winner (or selects none).
188
+ #### HIGHEST_CONFIDENCE_SINGLE
186
189
 
187
- Best for: creative tasks, subjective decisions, early human-in-the-loop workflows
190
+ Pick the submission with the highest declared confidence (optionally gated by `minConfidence`).
188
191
 
189
- Why it works: enables boards before full automation or formal scoring exists.
192
+ Best for: safety-sensitive workflows where false positives are expensive.
190
193
 
191
194
  Reward split (v1):
192
195
 
193
- - 100% of the reward goes to the submission explicitly selected by the owner.
194
- - If the owner selects no winner, no reward is paid.
195
- - The owner cannot retroactively split rewards unless the policy is reconfigured as `TOP_K_SPLIT`.
196
-
197
- #### TOP_K_SPLIT (K = 2 or 3)
196
+ - 100% of reward to the selected submission.
197
+ - Others receive 0%.
198
+ - If no submission meets threshold, no reward is paid.
198
199
 
199
- Select the top K submissions (by confidence or score) and split the reward.
200
+ #### OWNER_PICK
200
201
 
201
- Best for: research, exploration, “give me multiple good options”
202
+ The job creator selects a winning submission (or none).
202
203
 
203
- Why it works: increases participation and reduces winner-take-all pressure.
204
+ Best for: subjective/creative tasks or early HITL boards.
204
205
 
205
206
  Reward split (v1):
206
207
 
207
- - Reward is split evenly among the top K selected submissions.
208
- - Example: reward = 9, K = 3 → each winner receives 3.
209
- - If fewer than K valid submissions exist, reward is split evenly among those selected.
210
- - Submissions outside the top K receive 0%.
211
- - Ordering (confidence vs score) is defined explicitly by policy config.
212
-
213
- ### Voting-mode policies
214
-
215
- #### MAJORITY_VOTE
208
+ - 100% of reward to the owner-selected submission.
209
+ - If no winner is selected, no reward is paid.
216
210
 
217
- One principal = one vote, with quorum and close time.
211
+ #### TRUSTED_ARBITER
218
212
 
219
- Best for: simple classification, binary or categorical decisions
213
+ A designated arbiter resolves the job manually.
220
214
 
221
- Why it works: predictable and easy to reason about.
215
+ Best for: high-stakes workflows requiring explicit adjudication.
222
216
 
223
217
  Reward split (v1):
224
218
 
225
- - The entire reward is distributed equally among voters who voted with the winning outcome.
226
- - Example: reward = 10, 5 winning voters each receives 2.
227
- - Voters who voted for a losing option receive 0%.
228
- - If quorum is not met, no reward is paid and the job resolves as `NO_CONSENSUS`.
219
+ - 100% of reward to arbiter-selected submission.
220
+ - If arbiter does not select a winner, no reward is paid.
229
221
 
230
- #### WEIGHTED_VOTE_SIMPLE
222
+ ### Voting-oriented policy
231
223
 
232
- Votes are weighted by board-scoped weights set by an admin (not automatic reputation yet).
224
+ #### APPROVAL_VOTE
233
225
 
234
- Best for: trusted-expert boards, high-signal communities
226
+ Votes are tallied into per-submission scores. Settlement behavior is configurable:
235
227
 
236
- Why it works: allows asymmetric trust without full reputation systems.
228
+ - `immediate`: best eligible submission resolves automatically.
229
+ - `oracle`: vote scoring produces recommendation; oracle/manual step finalizes.
230
+ - `staked`: staked voting settlement path (local-first).
237
231
 
238
- Reward split (v1):
232
+ Common controls include quorum, minimum score, minimum margin, and tie-break.
233
+
234
+ Reward split (v1 default behavior):
239
235
 
240
- - The entire reward is distributed proportionally to vote weight among voters aligned with the winning outcome.
241
- - Example: total winning weight = 10, reward = 20 → each voter receives `(their_weight / 10) * 20`.
242
- - Voters on the losing side receive 0%.
243
- - If quorum (by total weight or count, policy-defined) is not met, no reward is paid.
236
+ - Reward goes to winning submission(s) per settlement mode.
237
+ - Losers receive 0%.
238
+ - If quorum/thresholds are not met, no reward is paid.
244
239
 
245
240
  ### Important v1 design principles
246
241
 
247
- - No partial rewards for losers in v1 clarity beats nuance.
248
- - No automatic slashing required for these policies to function.
249
- - Reward distribution is deterministic and auditable from the job record alone.
250
- - Boards may choose to return unallocated rewards to the creator or treasury.
242
+ - Reward outcomes are deterministic and auditable from job + vote/submission records.
243
+ - No partial loser rewards by default.
244
+ - Boards can choose return/unallocated behavior by config.
251
245
 
252
246
  ### Editing policy defaults
253
247
 
@@ -258,7 +252,7 @@ Defaults live under:
258
252
  - `local.consensusPolicies` (named policy presets)
259
253
  - `local.jobDefaults.consensusPolicy` (fallback when no key is provided)
260
254
 
261
- Use these fields to override any default:
255
+ Use these fields to override defaults:
262
256
 
263
257
  - `policyKey` to select a preset
264
258
  - `policyConfigJson` to override fields on the preset
@@ -268,8 +262,14 @@ Example:
268
262
 
269
263
  ```json
270
264
  {
271
- "policyKey": "TOP_K_SPLIT",
272
- "policyConfigJson": { "topK": 3, "ordering": "confidence" }
265
+ "policyKey": "APPROVAL_VOTE",
266
+ "policyConfigJson": {
267
+ "quorum": 3,
268
+ "minScore": 1,
269
+ "minMargin": 0,
270
+ "tieBreak": "earliest",
271
+ "approvalVote": { "weightMode": "equal", "settlement": "immediate" }
272
+ }
273
273
  }
274
274
  ```
275
275
 
@@ -386,7 +386,7 @@ Example (see full schema in `openclaw.plugin.json`):
386
386
  "maxParticipants": 3,
387
387
  "minParticipants": 1,
388
388
  "expiresSeconds": 86400,
389
- "consensusPolicy": { "type": "SINGLE_WINNER", "trustedArbiterAgentId": "" },
389
+ "consensusPolicy": { "type": "FIRST_SUBMISSION_WINS", "trustedArbiterAgentId": "", "tieBreak": "earliest" },
390
390
  "slashingPolicy": { "enabled": false, "slashPercent": 0, "slashFlat": 0 }
391
391
  },
392
392
  "ledger": { "faucetEnabled": false, "initialCreditsPerAgent": 0, "balancesMode": "initial", "balances": {} }
Binary file
@@ -61,8 +61,8 @@
61
61
  "properties": {
62
62
  "type": {
63
63
  "type": "string",
64
- "enum": ["SINGLE_WINNER", "MAJORITY_VOTE", "WEIGHTED_REPUTATION", "TRUSTED_ARBITER"],
65
- "default": "SINGLE_WINNER"
64
+ "enum": ["FIRST_SUBMISSION_WINS", "APPROVAL_VOTE", "MAJORITY_VOTE", "WEIGHTED_REPUTATION", "TRUSTED_ARBITER"],
65
+ "default": "FIRST_SUBMISSION_WINS"
66
66
  },
67
67
  "trustedArbiterAgentId": { "type": "string", "default": "" }
68
68
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consensus-tools/consensus-tools",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "bin": {
@@ -18,6 +18,7 @@
18
18
  "bin",
19
19
  "index.ts",
20
20
  "src",
21
+ "assets",
21
22
  "openclaw.plugin.json",
22
23
  "README.md"
23
24
  ],
package/src/cli.ts CHANGED
@@ -237,16 +237,23 @@ function registerConsensusSubcommands(
237
237
  .description('Cast a vote')
238
238
  .option('--submission <id>', 'Submission id to vote for')
239
239
  .option('--choice <key>', 'Choice key to vote for')
240
+ .option('--yes', 'Approval vote: yes (+1)')
241
+ .option('--no', 'Approval vote: no (-1)')
240
242
  .option('--weight <n>', 'Vote weight', parseFloat)
243
+ .option('--stake <n>', 'Stake amount locked for this vote (staked settlement)', parseFloat)
241
244
  .option('--json', 'JSON output')
242
245
  .action(async (jobId: string, opts: any) => {
246
+ const hasYesNo = Boolean(opts.yes || opts.no);
247
+ const score = hasYesNo ? (opts.no ? -1 : 1) : (opts.weight ?? 1);
248
+
243
249
  const vote = await backend.vote(agentId, jobId, {
244
250
  submissionId: opts.submission,
245
251
  choiceKey: opts.choice,
246
252
  targetType: opts.submission ? 'SUBMISSION' : opts.choice ? 'CHOICE' : undefined,
247
253
  targetId: opts.submission ?? opts.choice,
248
- weight: opts.weight ?? 1,
249
- score: opts.weight ?? 1
254
+ weight: opts.weight ?? (hasYesNo ? 1 : undefined),
255
+ score,
256
+ stakeAmount: opts.stake
250
257
  });
251
258
  output(vote, opts.json);
252
259
  });
@@ -582,6 +589,16 @@ function commonSh(): string {
582
589
  ' echo "$CONSENSUS_ROOT"',
583
590
  '}',
584
591
  '',
592
+ 'local_state_file() {',
593
+ ' # Engine-parity local state file (JsonStorage)',
594
+ ' if [[ -n "${CONSENSUS_STATE_FILE:-}" ]]; then',
595
+ ' echo "${CONSENSUS_STATE_FILE}"',
596
+ ' else',
597
+ ' echo "$(local_root)/state.json"',
598
+ ' fi',
599
+ '}',
600
+ '',
601
+ '',
585
602
  'ensure_local_board() {',
586
603
  ' local root; root="$(local_root)"',
587
604
  ' mkdir -p "$root/jobs"',
@@ -683,39 +700,35 @@ function jobsPostSh(): string {
683
700
  '',
684
701
  'POLICY="${CONSENSUS_DEFAULT_POLICY:-HIGHEST_CONFIDENCE_SINGLE}"',
685
702
  'REWARD="${CONSENSUS_DEFAULT_REWARD:-8}"',
686
- 'STAKE="${CONSENSUS_DEFAULT_STAKE:-4}"',
687
- 'LEASE_SECONDS="${CONSENSUS_DEFAULT_LEASE_SECONDS:-180}"',
703
+ 'STAKE_REQUIRED="${CONSENSUS_DEFAULT_STAKE:-4}"',
704
+ 'EXPIRES_SECONDS="${CONSENSUS_DEFAULT_EXPIRES_SECONDS:-86400}"',
688
705
  'MODE="$(mode)"',
689
706
  '',
690
707
  'if [[ "$MODE" == "local" ]]; then',
691
708
  ' ensure_local_board',
692
- ' local id; id="$(rand_id "job")"',
693
- '',
694
- ' local title_json desc_json input_json',
695
- ' title_json="$(json_escape "$TITLE")"',
696
- ' desc_json="$(json_escape "${DESC:-}")"',
697
- ' input_json="$(json_escape "${INPUT:-}")"',
698
- '',
699
- ' local job_json',
700
- ' job_json="$(cat <<JSON',
701
- '{',
702
- ' "id": "$id",',
703
- ' "title": $title_json,',
704
- ' "desc": $desc_json,',
705
- ' "input": $input_json,',
706
- ' "mode": "SUBMISSION",',
707
- ' "policyKey": "$POLICY",',
708
- ' "rewardAmount": $REWARD,',
709
- ' "stakeAmount": $STAKE,',
710
- ' "leaseSeconds": $LEASE_SECONDS,',
711
- ' "status": "OPEN",',
712
- ' "createdAt": "$(now_iso)"',
713
- '}',
714
- 'JSON',
715
- ')"',
716
- ' write_json_file "$(job_file "$id")" "$job_json"',
717
- ' ensure_job_dir "$id"',
718
- ' echo "$job_json"',
709
+ ' state_file="$(local_state_file)"',
710
+ ' node --import tsx --input-type=module - "$state_file" "$TITLE" "$DESC" "$INPUT" "$POLICY" "$REWARD" "$STAKE_REQUIRED" "$EXPIRES_SECONDS" <<\'NODE\'',
711
+ "import { JsonStorage } from '@consensus-tools/consensus-tools/src/storage/JsonStorage.ts';",
712
+ "import { LedgerEngine } from '@consensus-tools/consensus-tools/src/ledger/ledger.ts';",
713
+ "import { JobEngine } from '@consensus-tools/consensus-tools/src/jobs/engine.ts';",
714
+ "import { defaultConfig } from '@consensus-tools/consensus-tools/src/config.ts';",
715
+ 'const [stateFile,title,desc,input,policy,reward,stake,expires]=process.argv.slice(2);',
716
+ 'const storage=new JsonStorage(stateFile); await storage.init();',
717
+ 'const ledger=new LedgerEngine(storage, defaultConfig);',
718
+ 'const engine=new JobEngine(storage, ledger, defaultConfig);',
719
+ "const job = await engine.postJob('cli@local', {",
720
+ ' title,',
721
+ ' desc,',
722
+ ' inputRef: input,',
723
+ ' policyKey: policy,',
724
+ ' rewardAmount: Number(reward),',
725
+ ' reward: Number(reward),',
726
+ ' stakeRequired: Number(stake),',
727
+ ' stakeAmount: Number(stake),',
728
+ ' expiresSeconds: Number(expires)',
729
+ '});',
730
+ 'console.log(JSON.stringify(job, null, 2));',
731
+ 'NODE',
719
732
  ' exit 0',
720
733
  'fi',
721
734
  '',
@@ -729,14 +742,13 @@ function jobsPostSh(): string {
729
742
  ' "mode": "SUBMISSION",',
730
743
  ' "policyKey": "$POLICY",',
731
744
  ' "rewardAmount": $REWARD,',
732
- ' "stakeAmount": $STAKE,',
733
- ' "leaseSeconds": $LEASE_SECONDS',
745
+ ' "stakeAmount": $STAKE_REQUIRED,',
746
+ ' "leaseSeconds": ${CONSENSUS_DEFAULT_LEASE_SECONDS:-180}',
734
747
  '}',
735
748
  'JSON',
736
749
  ')"',
737
750
  'curl_json "POST" "$base/jobs" "$payload"',
738
751
  'echo',
739
- ''
740
752
  ].join('\n');
741
753
  }
742
754
 
@@ -756,14 +768,26 @@ function jobsGetSh(): string {
756
768
  '',
757
769
  'if [[ "$MODE" == "local" ]]; then',
758
770
  ' ensure_local_board',
759
- ' read_json_file "$(job_file "$JOB_ID")"',
771
+ ' state_file="$(local_state_file)"',
772
+ ' node --import tsx --input-type=module - "$state_file" "$JOB_ID" <<\'NODE\'',
773
+ "import { JsonStorage } from '@consensus-tools/consensus-tools/src/storage/JsonStorage.ts';",
774
+ "import { LedgerEngine } from '@consensus-tools/consensus-tools/src/ledger/ledger.ts';",
775
+ "import { JobEngine } from '@consensus-tools/consensus-tools/src/jobs/engine.ts';",
776
+ "import { defaultConfig } from '@consensus-tools/consensus-tools/src/config.ts';",
777
+ 'const [stateFile, jobId] = process.argv.slice(2);',
778
+ 'const storage=new JsonStorage(stateFile); await storage.init();',
779
+ 'const ledger=new LedgerEngine(storage, defaultConfig);',
780
+ 'const engine=new JobEngine(storage, ledger, defaultConfig);',
781
+ 'const job = await engine.getJob(jobId);',
782
+ "if (!job) { console.error('Job not found'); process.exit(1); }",
783
+ 'console.log(JSON.stringify(job, null, 2));',
784
+ 'NODE',
760
785
  ' exit 0',
761
786
  'fi',
762
787
  '',
763
788
  'base="$(remote_base)"',
764
789
  'curl -sS "$base/jobs/$JOB_ID" -H "$(remote_auth_header)"',
765
790
  'echo',
766
- ''
767
791
  ].join('\n');
768
792
  }
769
793
 
@@ -777,13 +801,23 @@ function jobsListSh(): string {
777
801
  '',
778
802
  'if [[ "$MODE" == "local" ]]; then',
779
803
  ' ensure_local_board',
780
- ' root="$(local_root)"',
781
- ' ls -1 "$root/jobs"/*.json 2>/dev/null | sed "s#.*/##" | sed "s#\\.json$##" || true',
804
+ ' state_file="$(local_state_file)"',
805
+ ' node --import tsx --input-type=module - "$state_file" <<\'NODE\'',
806
+ "import { JsonStorage } from '@consensus-tools/consensus-tools/src/storage/JsonStorage.ts';",
807
+ "import { LedgerEngine } from '@consensus-tools/consensus-tools/src/ledger/ledger.ts';",
808
+ "import { JobEngine } from '@consensus-tools/consensus-tools/src/jobs/engine.ts';",
809
+ "import { defaultConfig } from '@consensus-tools/consensus-tools/src/config.ts';",
810
+ 'const [stateFile]=process.argv.slice(2);',
811
+ 'const storage=new JsonStorage(stateFile); await storage.init();',
812
+ 'const ledger=new LedgerEngine(storage, defaultConfig);',
813
+ 'const engine=new JobEngine(storage, ledger, defaultConfig);',
814
+ 'const jobs = await engine.listJobs({});',
815
+ 'for (const j of jobs) console.log(j.id);',
816
+ 'NODE',
782
817
  ' exit 0',
783
818
  'fi',
784
819
  '',
785
820
  'base="$(remote_base)"',
786
- '# Optional: pass query string as $1, e.g. "status=OPEN&mode=SUBMISSION"',
787
821
  'QS="${1:-}"',
788
822
  'url="$base/jobs"',
789
823
  'if [[ -n "$QS" ]]; then',
@@ -791,7 +825,6 @@ function jobsListSh(): string {
791
825
  'fi',
792
826
  'curl -sS "$url" -H "$(remote_auth_header)"',
793
827
  'echo',
794
- ''
795
828
  ].join('\n');
796
829
  }
797
830
 
@@ -804,10 +837,10 @@ function submissionsCreateSh(): string {
804
837
  'JOB_ID="${1:-}"',
805
838
  'ARTIFACT_JSON="${2:-}"',
806
839
  'SUMMARY="${3:-}"',
840
+ 'CONFIDENCE="${4:-0.5}"',
807
841
  '',
808
842
  'if [[ -z "$JOB_ID" || -z "$ARTIFACT_JSON" ]]; then',
809
- ' echo "Usage: submissions_create.sh <jobId> <artifact_json> [summary]" >&2',
810
- ' echo "Example: submissions_create.sh job_... {\\\"toxic\\\":false,\\\"confidence\\\":0.98,\\\"brief_reason\\\":\\\"...\\\"}" >&2',
843
+ ' echo "Usage: submissions_create.sh <jobId> <artifact_json> [summary] [confidence]" >&2',
811
844
  ' exit 2',
812
845
  'fi',
813
846
  '',
@@ -815,23 +848,20 @@ function submissionsCreateSh(): string {
815
848
  '',
816
849
  'if [[ "$MODE" == "local" ]]; then',
817
850
  ' ensure_local_board',
818
- ' ensure_job_dir "$JOB_ID"',
819
- '',
820
- ' sid="$(rand_id "sub")"',
821
- ' summary_json="$(json_escape "${SUMMARY:-}")"',
822
- ' sub_json="$(cat <<JSON',
823
- '{',
824
- ' "id": "$sid",',
825
- ' "jobId": "$JOB_ID",',
826
- ' "artifact": $ARTIFACT_JSON,',
827
- ' "summary": $summary_json,',
828
- ' "createdAt": "$(now_iso)",',
829
- ' "status": "VALID"',
830
- '}',
831
- 'JSON',
832
- ')"',
833
- ' write_json_file "$(job_dir "$JOB_ID")/submissions/${sid}.json" "$sub_json"',
834
- ' echo "$sub_json"',
851
+ ' state_file="$(local_state_file)"',
852
+ ' node --import tsx --input-type=module - "$state_file" "$JOB_ID" "$ARTIFACT_JSON" "${SUMMARY:-}" "$CONFIDENCE" <<\'NODE\'',
853
+ "import { JsonStorage } from '@consensus-tools/consensus-tools/src/storage/JsonStorage.ts';",
854
+ "import { LedgerEngine } from '@consensus-tools/consensus-tools/src/ledger/ledger.ts';",
855
+ "import { JobEngine } from '@consensus-tools/consensus-tools/src/jobs/engine.ts';",
856
+ "import { defaultConfig } from '@consensus-tools/consensus-tools/src/config.ts';",
857
+ 'const [stateFile, jobId, artifactJson, summary, conf] = process.argv.slice(2);',
858
+ 'const artifacts = JSON.parse(artifactJson);',
859
+ 'const storage=new JsonStorage(stateFile); await storage.init();',
860
+ 'const ledger=new LedgerEngine(storage, defaultConfig);',
861
+ 'const engine=new JobEngine(storage, ledger, defaultConfig);',
862
+ "const sub = await engine.submitJob('cli@local', jobId, { summary: summary || '', artifacts, confidence: Number(conf) });",
863
+ 'console.log(JSON.stringify(sub, null, 2));',
864
+ 'NODE',
835
865
  ' exit 0',
836
866
  'fi',
837
867
  '',
@@ -845,7 +875,6 @@ function submissionsCreateSh(): string {
845
875
  ')"',
846
876
  'curl_json "POST" "$base/jobs/$JOB_ID/submissions" "$payload"',
847
877
  'echo',
848
- ''
849
878
  ].join('\n');
850
879
  }
851
880
 
@@ -865,15 +894,25 @@ function submissionsListSh(): string {
865
894
  '',
866
895
  'if [[ "$MODE" == "local" ]]; then',
867
896
  ' ensure_local_board',
868
- ' ensure_job_dir "$JOB_ID"',
869
- ' ls -1 "$(job_dir "$JOB_ID")/submissions"/*.json 2>/dev/null | xargs -I{} cat "{}" || true',
897
+ ' state_file="$(local_state_file)"',
898
+ ' node --import tsx --input-type=module - "$state_file" "$JOB_ID" <<\'NODE\'',
899
+ "import { JsonStorage } from '@consensus-tools/consensus-tools/src/storage/JsonStorage.ts';",
900
+ "import { LedgerEngine } from '@consensus-tools/consensus-tools/src/ledger/ledger.ts';",
901
+ "import { JobEngine } from '@consensus-tools/consensus-tools/src/jobs/engine.ts';",
902
+ "import { defaultConfig } from '@consensus-tools/consensus-tools/src/config.ts';",
903
+ 'const [stateFile, jobId] = process.argv.slice(2);',
904
+ 'const storage=new JsonStorage(stateFile); await storage.init();',
905
+ 'const ledger=new LedgerEngine(storage, defaultConfig);',
906
+ 'const engine=new JobEngine(storage, ledger, defaultConfig);',
907
+ 'const list = await engine.listSubmissions(jobId);',
908
+ 'for (const s of list) console.log(JSON.stringify(s, null, 2));',
909
+ 'NODE',
870
910
  ' exit 0',
871
911
  'fi',
872
912
  '',
873
913
  'base="$(remote_base)"',
874
914
  'curl -sS "$base/jobs/$JOB_ID/submissions" -H "$(remote_auth_header)"',
875
915
  'echo',
876
- ''
877
916
  ].join('\n');
878
917
  }
879
918
 
@@ -884,14 +923,27 @@ function votesCastSh(): string {
884
923
  'source "$(dirname "$0")/common.sh"',
885
924
  '',
886
925
  'JOB_ID="${1:-}"',
887
- 'TARGET_TYPE="${2:-}" # SUBMISSION or CHOICE',
888
- 'TARGET_ID="${3:-}" # submission id or choice key',
889
- 'WEIGHT="${4:-1}"',
926
+ 'shift || true',
927
+ 'TARGET_TYPE=""',
928
+ 'TARGET_ID=""',
929
+ 'WEIGHT="1"',
930
+ '',
931
+ 'if [[ "${1:-}" == "SUBMISSION" || "${1:-}" == "CHOICE" ]]; then',
932
+ ' TARGET_TYPE="$1"; TARGET_ID="${2:-}"; WEIGHT="${3:-1}"',
933
+ 'else',
934
+ ' while [[ $# -gt 0 ]]; do',
935
+ ' case "$1" in',
936
+ ' --submission) TARGET_TYPE="SUBMISSION"; TARGET_ID="${2:-}"; shift 2 ;;',
937
+ ' --choice) TARGET_TYPE="CHOICE"; TARGET_ID="${2:-}"; shift 2 ;;',
938
+ ' --weight) WEIGHT="${2:-1}"; shift 2 ;;',
939
+ ' *) echo "Unknown arg: $1" >&2; exit 2 ;;',
940
+ ' esac',
941
+ ' done',
942
+ 'fi',
890
943
  '',
891
944
  'if [[ -z "$JOB_ID" || -z "$TARGET_TYPE" || -z "$TARGET_ID" ]]; then',
892
- ' echo "Usage: votes_cast.sh <jobId> <targetType:SUBMISSION|CHOICE> <targetId> [weight]" >&2',
893
- ' echo "Example: votes_cast.sh job_... SUBMISSION sub_... 1" >&2',
894
- ' echo "Example: votes_cast.sh job_... CHOICE TOXIC_FALSE 1" >&2',
945
+ ' echo "Usage: votes_cast.sh <jobId> SUBMISSION <submissionId> [weight]" >&2',
946
+ ' echo " or: votes_cast.sh <jobId> --submission <id> [--weight <n>]" >&2',
895
947
  ' exit 2',
896
948
  'fi',
897
949
  '',
@@ -899,22 +951,19 @@ function votesCastSh(): string {
899
951
  '',
900
952
  'if [[ "$MODE" == "local" ]]; then',
901
953
  ' ensure_local_board',
902
- ' ensure_job_dir "$JOB_ID"',
903
- '',
904
- ' vid="$(rand_id "vote")"',
905
- ' vote_json="$(cat <<JSON',
906
- '{',
907
- ' "id": "$vid",',
908
- ' "jobId": "$JOB_ID",',
909
- ' "targetType": "$TARGET_TYPE",',
910
- ' "targetId": "$TARGET_ID",',
911
- ' "weight": $WEIGHT,',
912
- ' "createdAt": "$(now_iso)"',
913
- '}',
914
- 'JSON',
915
- ')"',
916
- ' write_json_file "$(job_dir "$JOB_ID")/votes/${vid}.json" "$vote_json"',
917
- ' echo "$vote_json"',
954
+ ' state_file="$(local_state_file)"',
955
+ ' node --import tsx --input-type=module - "$state_file" "$JOB_ID" "$TARGET_TYPE" "$TARGET_ID" "$WEIGHT" <<\'NODE\'',
956
+ "import { JsonStorage } from '@consensus-tools/consensus-tools/src/storage/JsonStorage.ts';",
957
+ "import { LedgerEngine } from '@consensus-tools/consensus-tools/src/ledger/ledger.ts';",
958
+ "import { JobEngine } from '@consensus-tools/consensus-tools/src/jobs/engine.ts';",
959
+ "import { defaultConfig } from '@consensus-tools/consensus-tools/src/config.ts';",
960
+ 'const [stateFile, jobId, targetType, targetId, weight] = process.argv.slice(2);',
961
+ 'const storage=new JsonStorage(stateFile); await storage.init();',
962
+ 'const ledger=new LedgerEngine(storage, defaultConfig);',
963
+ 'const engine=new JobEngine(storage, ledger, defaultConfig);',
964
+ "const vote = await engine.vote('cli@local', jobId, { targetType, targetId, submissionId: targetType==='SUBMISSION'?targetId:undefined, choiceKey: targetType==='CHOICE'?targetId:undefined, weight: Number(weight), score: Number(weight) });",
965
+ 'console.log(JSON.stringify(vote, null, 2));',
966
+ 'NODE',
918
967
  ' exit 0',
919
968
  'fi',
920
969
  '',
@@ -929,7 +978,6 @@ function votesCastSh(): string {
929
978
  ')"',
930
979
  'curl_json "POST" "$base/jobs/$JOB_ID/votes" "$payload"',
931
980
  'echo',
932
- ''
933
981
  ].join('\n');
934
982
  }
935
983
 
@@ -949,15 +997,25 @@ function votesListSh(): string {
949
997
  '',
950
998
  'if [[ "$MODE" == "local" ]]; then',
951
999
  ' ensure_local_board',
952
- ' ensure_job_dir "$JOB_ID"',
953
- ' ls -1 "$(job_dir "$JOB_ID")/votes"/*.json 2>/dev/null | xargs -I{} cat "{}" || true',
1000
+ ' state_file="$(local_state_file)"',
1001
+ ' node --import tsx --input-type=module - "$state_file" "$JOB_ID" <<\'NODE\'',
1002
+ "import { JsonStorage } from '@consensus-tools/consensus-tools/src/storage/JsonStorage.ts';",
1003
+ "import { LedgerEngine } from '@consensus-tools/consensus-tools/src/ledger/ledger.ts';",
1004
+ "import { JobEngine } from '@consensus-tools/consensus-tools/src/jobs/engine.ts';",
1005
+ "import { defaultConfig } from '@consensus-tools/consensus-tools/src/config.ts';",
1006
+ 'const [stateFile, jobId] = process.argv.slice(2);',
1007
+ 'const storage=new JsonStorage(stateFile); await storage.init();',
1008
+ 'const ledger=new LedgerEngine(storage, defaultConfig);',
1009
+ 'const engine=new JobEngine(storage, ledger, defaultConfig);',
1010
+ 'const list = await engine.listVotes(jobId);',
1011
+ 'for (const v of list) console.log(JSON.stringify(v, null, 2));',
1012
+ 'NODE',
954
1013
  ' exit 0',
955
1014
  'fi',
956
1015
  '',
957
1016
  'base="$(remote_base)"',
958
1017
  'curl -sS "$base/jobs/$JOB_ID/votes" -H "$(remote_auth_header)"',
959
1018
  'echo',
960
- ''
961
1019
  ].join('\n');
962
1020
  }
963
1021
 
@@ -968,8 +1026,20 @@ function resolveSh(): string {
968
1026
  'source "$(dirname "$0")/common.sh"',
969
1027
  '',
970
1028
  'JOB_ID="${1:-}"',
1029
+ 'shift || true',
1030
+ 'MANUAL_WINNER=""',
1031
+ 'MANUAL_SUB=""',
1032
+ '',
1033
+ 'while [[ $# -gt 0 ]]; do',
1034
+ ' case "$1" in',
1035
+ ' --winner) MANUAL_WINNER="${2:-}"; shift 2 ;;',
1036
+ ' --submission) MANUAL_SUB="${2:-}"; shift 2 ;;',
1037
+ ' *) echo "Unknown arg: $1" >&2; exit 2 ;;',
1038
+ ' esac',
1039
+ 'done',
1040
+ '',
971
1041
  'if [[ -z "$JOB_ID" ]]; then',
972
- ' echo "Usage: resolve.sh <jobId>" >&2',
1042
+ ' echo "Usage: resolve.sh <jobId> [--winner <agentId>] [--submission <submissionId>]" >&2',
973
1043
  ' exit 2',
974
1044
  'fi',
975
1045
  '',
@@ -977,56 +1047,29 @@ function resolveSh(): string {
977
1047
  '',
978
1048
  'if [[ "$MODE" == "local" ]]; then',
979
1049
  ' ensure_local_board',
980
- ' ensure_job_dir "$JOB_ID"',
981
- '',
982
- ' # Local resolution policy: HIGHEST_CONFIDENCE_SINGLE for SUBMISSION jobs.',
983
- ' # We pick the submission with max artifact.confidence if present.',
984
- ' # If missing, we fall back to the most recent submission.',
985
- '',
986
- ' dir="$(job_dir "$JOB_ID")/submissions"',
987
- ' if ! ls "$dir"/*.json >/dev/null 2>&1; then',
988
- ' echo "No submissions found for $JOB_ID" >&2',
989
- ' exit 1',
990
- ' fi',
991
- '',
992
- ' python3 - <<\'PY\' "$JOB_ID" "$dir" | tee "$(job_dir "$JOB_ID")/result.json"',
993
- 'import json,glob,sys,os',
994
- 'job_id=sys.argv[1]; d=sys.argv[2]',
995
- 'subs=[]',
996
- 'for p in glob.glob(os.path.join(d,"*.json")):',
997
- ' with open(p,"r") as f:',
998
- ' s=json.load(f)',
999
- ' conf=None',
1000
- ' try:',
1001
- ' conf=float(s.get("artifact",{}).get("confidence"))',
1002
- ' except Exception:',
1003
- ' conf=None',
1004
- ' subs.append((conf,s.get("createdAt",""),s,p))',
1005
- '# sort: confidence desc (None last), then createdAt desc',
1006
- 'def key(t):',
1007
- ' conf,created,_,_ = t',
1008
- ' return (conf is not None, conf if conf is not None else -1.0, created)',
1009
- 'subs_sorted=sorted(subs, key=key, reverse=True)',
1010
- 'conf,created,s,p=subs_sorted[0]',
1011
- 'result={',
1012
- ' "jobId": job_id,',
1013
- ' "mode": "SUBMISSION",',
1014
- ' "selectedSubmissionId": s.get("id"),',
1015
- ' "selectedSubmissionPath": p,',
1016
- ' "resolvedAt": __import__("datetime").datetime.utcnow().isoformat()+"Z",',
1017
- ' "artifact": s.get("artifact"),',
1018
- ' "summary": s.get("summary","")',
1019
- '}',
1020
- 'print(json.dumps(result, indent=2))',
1021
- 'PY',
1022
- '',
1050
+ ' state_file="$(local_state_file)"',
1051
+ ' node --import tsx --input-type=module - "$state_file" "$JOB_ID" "$MANUAL_WINNER" "$MANUAL_SUB" <<\'NODE\'',
1052
+ "import { JsonStorage } from '@consensus-tools/consensus-tools/src/storage/JsonStorage.ts';",
1053
+ "import { LedgerEngine } from '@consensus-tools/consensus-tools/src/ledger/ledger.ts';",
1054
+ "import { JobEngine } from '@consensus-tools/consensus-tools/src/jobs/engine.ts';",
1055
+ "import { defaultConfig } from '@consensus-tools/consensus-tools/src/config.ts';",
1056
+ 'const [stateFile, jobId, winner, subId] = process.argv.slice(2);',
1057
+ 'const storage=new JsonStorage(stateFile); await storage.init();',
1058
+ 'const ledger=new LedgerEngine(storage, defaultConfig);',
1059
+ 'const engine=new JobEngine(storage, ledger, defaultConfig);',
1060
+ 'const input:any = {};',
1061
+ 'if (winner) input.manualWinners = [winner];',
1062
+ 'if (subId) input.manualSubmissionId = subId;',
1063
+ "const actor = winner || 'cli@local';",
1064
+ 'const res = await engine.resolveJob(actor, jobId, input);',
1065
+ 'console.log(JSON.stringify(res, null, 2));',
1066
+ 'NODE',
1023
1067
  ' exit 0',
1024
1068
  'fi',
1025
1069
  '',
1026
1070
  'base="$(remote_base)"',
1027
1071
  'curl -sS -X POST "$base/jobs/$JOB_ID/resolve" -H "$(remote_auth_header)"',
1028
1072
  'echo',
1029
- ''
1030
1073
  ].join('\n');
1031
1074
  }
1032
1075
 
@@ -1046,14 +1089,24 @@ function resultGetSh(): string {
1046
1089
  '',
1047
1090
  'if [[ "$MODE" == "local" ]]; then',
1048
1091
  ' ensure_local_board',
1049
- ' path="$(job_dir "$JOB_ID")/result.json"',
1050
- ' read_json_file "$path"',
1092
+ ' state_file="$(local_state_file)"',
1093
+ ' node --import tsx --input-type=module - "$state_file" "$JOB_ID" <<\'NODE\'',
1094
+ "import { JsonStorage } from '@consensus-tools/consensus-tools/src/storage/JsonStorage.ts';",
1095
+ "import { LedgerEngine } from '@consensus-tools/consensus-tools/src/ledger/ledger.ts';",
1096
+ "import { JobEngine } from '@consensus-tools/consensus-tools/src/jobs/engine.ts';",
1097
+ "import { defaultConfig } from '@consensus-tools/consensus-tools/src/config.ts';",
1098
+ 'const [stateFile, jobId] = process.argv.slice(2);',
1099
+ 'const storage=new JsonStorage(stateFile); await storage.init();',
1100
+ 'const ledger=new LedgerEngine(storage, defaultConfig);',
1101
+ 'const engine=new JobEngine(storage, ledger, defaultConfig);',
1102
+ 'const status = await engine.getStatus(jobId);',
1103
+ 'console.log(JSON.stringify(status.resolution ?? null, null, 2));',
1104
+ 'NODE',
1051
1105
  ' exit 0',
1052
1106
  'fi',
1053
1107
  '',
1054
1108
  'base="$(remote_base)"',
1055
1109
  'curl -sS "$base/jobs/$JOB_ID/result" -H "$(remote_auth_header)"',
1056
1110
  'echo',
1057
- ''
1058
1111
  ].join('\n');
1059
1112
  }
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' },
package/src/initWizard.ts CHANGED
@@ -12,9 +12,10 @@ export type InitWizardResult = {
12
12
 
13
13
  const POLICY_CHOICES = [
14
14
  'HIGHEST_CONFIDENCE_SINGLE',
15
+ 'APPROVAL_VOTE',
15
16
  'TOP_K_SPLIT',
16
17
  'OWNER_PICK',
17
- 'SINGLE_WINNER',
18
+ 'FIRST_SUBMISSION_WINS',
18
19
  'MAJORITY_VOTE',
19
20
  'WEIGHTED_VOTE_SIMPLE',
20
21
  'WEIGHTED_REPUTATION',
@@ -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;
package/src/standalone.ts CHANGED
@@ -163,7 +163,7 @@ function helpText() {
163
163
  ' jobs list [--tag <tag>] [--status <status>] [--mine] [--json]',
164
164
  ' submissions create <jobId> --artifact <json> [--summary <text>] [--confidence <n>] [--json]',
165
165
  ' submissions list <jobId> [--json]',
166
- ' votes cast <jobId> [--submission <id> | --choice <key>] [--weight <n>] [--json]',
166
+ ' votes cast <jobId> [--submission <id> | --choice <key>] [--yes|--no] [--weight <n>] [--stake <n>] [--json]',
167
167
  ' votes list <jobId> [--json]',
168
168
  ' resolve <jobId> [--winner <agentId> ...] [--submission <submissionId>] [--json]',
169
169
  ' result get <jobId> [--json]',
@@ -54,8 +54,9 @@ export type RunnerResult = {
54
54
  };
55
55
 
56
56
  const POLICY_TYPES: ConsensusPolicyType[] = [
57
- 'SINGLE_WINNER',
57
+ 'FIRST_SUBMISSION_WINS',
58
58
  'HIGHEST_CONFIDENCE_SINGLE',
59
+ 'APPROVAL_VOTE',
59
60
  'OWNER_PICK',
60
61
  'TOP_K_SPLIT',
61
62
  'MAJORITY_VOTE',
@@ -95,7 +96,13 @@ export async function runConsensusPolicyTests(options: RunnerOptions): Promise<R
95
96
  trustedArbiterAgentId: policy === 'TRUSTED_ARBITER' ? 'arbiter' : '',
96
97
  minConfidence: policy === 'HIGHEST_CONFIDENCE_SINGLE' ? 0.5 : undefined,
97
98
  topK: policy === 'TOP_K_SPLIT' ? 2 : undefined,
98
- ordering: policy === 'TOP_K_SPLIT' ? 'confidence' : undefined
99
+ ordering: policy === 'TOP_K_SPLIT' ? 'confidence' : undefined,
100
+ // APPROVAL_VOTE defaults (pure approval): require at least 1 vote and accept any positive score
101
+ quorum: policy === 'APPROVAL_VOTE' ? 1 : undefined,
102
+ minScore: policy === 'APPROVAL_VOTE' ? 1 : undefined,
103
+ minMargin: policy === 'APPROVAL_VOTE' ? 0 : undefined,
104
+ tieBreak: policy === 'APPROVAL_VOTE' ? 'earliest' : undefined,
105
+ approvalVote: policy === 'APPROVAL_VOTE' ? { weightMode: 'equal', settlement: 'immediate' } : undefined
99
106
  },
100
107
  slashingPolicy: { enabled: false, slashPercent: 0, slashFlat: 0 }
101
108
  });
@@ -122,7 +129,7 @@ export async function runConsensusPolicyTests(options: RunnerOptions): Promise<R
122
129
  const correctSubmission = submissions.find((sub) => sub.artifacts?.answer === script.expectedAnswer) || submissions[0];
123
130
  log('[submission] selected correct', { submissionId: correctSubmission.id, agentId: correctSubmission.agentId });
124
131
 
125
- if (policy === 'MAJORITY_VOTE' || policy === 'WEIGHTED_VOTE_SIMPLE' || policy === 'WEIGHTED_REPUTATION') {
132
+ if (policy === 'APPROVAL_VOTE' || policy === 'MAJORITY_VOTE' || policy === 'WEIGHTED_VOTE_SIMPLE' || policy === 'WEIGHTED_REPUTATION') {
126
133
  for (const persona of personas) {
127
134
  await engine.vote(persona.id, job.id, {
128
135
  submissionId: correctSubmission.id,
package/src/types.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export type ConsensusPolicyType =
2
- | 'SINGLE_WINNER'
2
+ | 'FIRST_SUBMISSION_WINS'
3
3
  | 'HIGHEST_CONFIDENCE_SINGLE'
4
+ | 'APPROVAL_VOTE'
4
5
  | 'OWNER_PICK'
5
6
  | 'TOP_K_SPLIT'
6
7
  | 'MAJORITY_VOTE'
@@ -24,10 +25,27 @@ export type VoteTargetType = 'SUBMISSION' | 'CHOICE';
24
25
  export interface ConsensusPolicyConfig {
25
26
  type: ConsensusPolicyType;
26
27
  trustedArbiterAgentId?: string;
28
+
29
+ // confidence-based policies
27
30
  minConfidence?: number;
31
+
32
+ // top-k policies
28
33
  topK?: number;
29
34
  ordering?: 'confidence' | 'score';
35
+
36
+ // vote-based policies
30
37
  quorum?: number;
38
+ minScore?: number;
39
+ minMargin?: number;
40
+ tieBreak?: 'earliest' | 'confidence' | 'arbiter';
41
+
42
+ // approval-vote specific
43
+ approvalVote?: {
44
+ weightMode?: 'equal' | 'explicit' | 'reputation';
45
+ settlement?: 'immediate' | 'staked' | 'oracle';
46
+ oracle?: 'trusted_arbiter';
47
+ voteSlashPercent?: number; // 0-1 (only for settlement=staked)
48
+ };
31
49
  }
32
50
 
33
51
  export interface SlashingPolicy {
@@ -162,7 +180,14 @@ export interface DiagnosticEntry {
162
180
  context?: Record<string, unknown>;
163
181
  }
164
182
 
165
- export type LedgerEntryType = 'FAUCET' | 'STAKE' | 'UNSTAKE' | 'PAYOUT' | 'SLASH' | 'ADJUST' | 'ESCROW_MINT';
183
+ export type LedgerEntryType =
184
+ | 'FAUCET'
185
+ | 'STAKE'
186
+ | 'UNSTAKE'
187
+ | 'PAYOUT'
188
+ | 'SLASH'
189
+ | 'ADJUST'
190
+ | 'ESCROW_MINT';
166
191
 
167
192
  export interface LedgerEntry {
168
193
  id: string;