cascrow-cli 1.4.0 → 3.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/index.js CHANGED
@@ -6,10 +6,93 @@
6
6
  */
7
7
 
8
8
  import { readFileSync } from "node:fs";
9
+ import { spawn } from "node:child_process";
9
10
 
10
11
  const BASE_URL = process.env.CASCROW_BASE_URL ?? "https://cascrow.com";
11
12
  const API_KEY = process.env.CASCROW_API_KEY ?? "";
12
13
 
14
+ // What every cascrow client says about MilestoneEscrowV3 — a JSON copy of
15
+ // V3_CLIENT_TEXT in src/lib/v3-client-text.ts (this package cannot import the
16
+ // app). v3-client-text-parity.test.ts holds the copy equal to the owner.
17
+ // BEGIN v3-client-text
18
+ const V3_CLIENT_TEXT = {
19
+ "signerPin": "cascrow-signer@0.2.0",
20
+ "remoteTools": [
21
+ "cascrow_v3_offer",
22
+ "cascrow_v3_acceptance_get",
23
+ "cascrow_v3_acceptance_submit",
24
+ "cascrow_v3_prepare",
25
+ "cascrow_v3_report_tx",
26
+ "cascrow_v3_attestation",
27
+ "cascrow_v3_claims"
28
+ ],
29
+ "codeHints": {
30
+ "V3_DISABLED": "MilestoneEscrowV3 is switched off for NEW money right now (offers, acceptances, funding). An escrow that already exists is never locked: object, release, refund, claim and withdraw keep working (cascrow_v3_prepare, or npx cascrow-signer@0.2.0 without cascrow at all).",
31
+ "RATE_UNAVAILABLE": "cascrow could not get a consistent XRP/USD rate from its two sources, so no offer can be priced. Nothing was created; retry in a minute.",
32
+ "SCREENING_UNAVAILABLE": "cascrow could not complete the sanctions screening this new-escrow step needs (offer, acceptance or funding), so nothing was created or prepared. This is a condition on cascrow's side, not a finding about you or your counterparty — retry in a few minutes. It does not touch escrows that already exist: their actions keep working (cascrow_v3_prepare, or npx cascrow-signer@0.2.0).",
33
+ "V3_NOT_OFFERED": "The payer has not offered MilestoneEscrowV3 terms for this milestone yet — only the payer can (cascrow_v3_offer). The payer has been notified.",
34
+ "OFFER_EXISTS": "An open offer already exists for this milestone and stays valid until it expires. Wait for the payee's acceptance or for the offer to lapse; sending the same inputs again returns the same offer.",
35
+ "V3_OFFER_OPEN": "An open MilestoneEscrowV3 offer, or a signed acceptance that is not funded yet, is bound to this milestone or wallet, so it cannot change now. Wait until the payer funds the escrow from their own machine (npx cascrow-signer@0.2.0 fund --milestone <milestoneId>) or the offer or acceptance lapses.",
36
+ "ATTESTATION_PENDING": "The AI panel has not finished on this claim. Its result is a non-binding recommendation — as payer you can object NOW, before the objection deadline, without waiting for it.",
37
+ "CLAIM_PROOF_UNKNOWN": "This claim's proofHash matches no evidence submitted to cascrow, so there is no AI recommendation for it. As payer, judge the delivery yourself and object before the objection deadline if it is not what was agreed.",
38
+ "V3_ESCROW": "This milestone is held in a MilestoneEscrowV3 escrow: only its payer, payee or arbiter moves that money, on chain, with their own key. Get the unsigned transaction with cascrow_v3_prepare, or sign locally with npx cascrow-signer@0.2.0.",
39
+ "V2_ESCROW_PRESENT": "This milestone or its contract still carries data of the retired v2 (RLUSD) escrow, so it cannot become a MilestoneEscrowV3 escrow. Nothing was changed; contact support.",
40
+ "INVALID_SIGNATURE": "The acceptance signature does not verify for your linked wallet over these exact terms (EIP-712, low-s, from an ordinary account). Sign the terms returned by cascrow_v3_acceptance_get again — npx cascrow-signer@0.2.0 accept does it and checks them first.",
41
+ "V3_INDEX_STALE": "cascrow's index of the chain is behind. Read the escrow from the chain directly (npx cascrow-signer@0.2.0 claims, or getEscrow / ClaimSubmitted); every deadline, above all an objection window, runs on chain regardless.",
42
+ "ACCEPTANCE_LAPSED": "The acceptance expired without funding, so the milestone is closed — this is not a failure of either party. A new attempt is a new milestone.",
43
+ "NOT_COMPLETABLE_WITHOUT_ESCROW": "The work was approved, but this milestone carries an amount without a MilestoneEscrowV3 escrow, and the previous escrow system has been removed — nothing can complete or pay it, so it was left as it was and NO money moved. cascrow has opened a case for it; contact support@cascrow.com with the contract ID. Do not resubmit: your evidence is recorded.",
44
+ "RELEASE_HOLD_REMOVED": "releaseHoldHours was removed with the previous escrow system; send the contract without it. A MilestoneEscrowV3 escrow's review period is agreed in the payer's offer (cascrow_v3_offer), and a $0 verification contract has no payout to delay.",
45
+ "AMOUNT_VIA_MILESTONES": "This draft has milestones, and their amounts are the contract's amount — it cannot be set on the contract. Nothing was changed. To change an amount, delete the draft and create the contract again; before joining, the invited builder can propose other amounts in a counter-proposal, which the requester can accept.",
46
+ "DEADLINE_VIA_MILESTONES": "This draft has milestones, and their deadlines are the ones enforced — a contract-level deadline changes nothing, so it is refused. Nothing was changed. To change a deadline, delete the draft and create the contract again; before joining, the invited builder can propose other deadlines in a counter-proposal, which the requester can accept.",
47
+ "MIXED_AMOUNTS": "A contract is either verification-only (every milestone $0) or escrowed (every milestone above $0), never both. Nothing was created or changed. Set every milestone to $0, or give every milestone an amount and fund each one through a MilestoneEscrowV3 offer (cascrow_v3_offer) once the builder has joined."
48
+ },
49
+ "joined": {
50
+ "active": "Joined as Builder. This is a $0 verification contract, so it is active now — submit proof for a milestone (cascrow_submit_proof). No money is held or moved.",
51
+ "awaitingOffer": "Joined as Builder. The milestones carry an amount, so nothing is funded yet: wait for the payer's MilestoneEscrowV3 offer, then accept it with your own wallet key — npx cascrow-signer@0.2.0 accept --milestone <milestoneId>, or on the contract page. cascrow never signs for you. Submit proof once the payer has funded the milestone.",
52
+ "unknown": "Joined as Builder. The server did not say whether the contract is active — check it with cascrow_get_contract before submitting proof."
53
+ },
54
+ "payeeHints": {
55
+ "CLAIM_PROOF_UNKNOWN": "This manifest is not a sealed submission of this milestone, so cascrow will not confirm it and nothing was signed. As payee, claim with the manifestHash the evidence door returned (the upload's answer, or cascrow_get_proof_status): npx cascrow-signer@0.2.0 submit-claim --milestone <milestoneId> --proof-hash <manifestHash>."
56
+ },
57
+ "fundRemoved": "cascrow_fund_milestone was removed (and with it fundMilestone in cascrow-sdk and `cascrow fund`). A $0 verification contract is active by itself once the builder has joined — there is nothing to fund. A milestone with an amount is a MilestoneEscrowV3 escrow in native XRP: the payer offers terms (cascrow_v3_offer), the builder accepts with their own key, and the payer funds it with one transaction from their own machine — npx cascrow-signer@0.2.0 fund --milestone <milestoneId>. cascrow never signs or sends for you.",
58
+ "recommendation": "🧭 AI RECOMMENDATION {verdict} for this MilestoneEscrowV3 submission ({confidence}% confidence on this file) — this verdict moves NO money. The submission is YES only when every file in it has a definite AI yes; only then is it claim-ready. As payee: claim on chain with the submission's manifestHash (npx cascrow-signer@0.2.0 submit-claim --milestone <milestoneId> --proof-hash <manifestHash>). As payer: release, or object before the objection deadline.",
59
+ "noArbiterVeto": "No arbiter is named. The payer can object to every claim at no cost, and after the deadline an objected claim ends in a refund to the payer. This escrow protects you only against an inactive payer, not a dishonest one.",
60
+ "reportTxOnlyHash": "cascrow_v3_report_tx takes a transaction HASH (0x + 64 hex characters) and nothing else. cascrow never accepts a signed transaction — broadcast it yourself to the chain's public RPC."
61
+ };
62
+ // END v3-client-text
63
+ const TX_HASH_RE = /^0x[0-9a-fA-F]{64}$/;
64
+ function v3HintFor(code, body) {
65
+ if (!code) return null;
66
+ // Role-aware (post-review H9): CLAIM_PROOF_UNKNOWN without objectionDeadline comes from
67
+ // prepare?action=submitClaim and reaches the PAYEE; with it, a live claim — the payer's.
68
+ const b = body && typeof body === "object" ? body : null;
69
+ const liveClaim = !!b && typeof b.objectionDeadline === "string";
70
+ if (b && !liveClaim && V3_CLIENT_TEXT.payeeHints[code]) return V3_CLIENT_TEXT.payeeHints[code];
71
+ return V3_CLIENT_TEXT.codeHints[code] || null;
72
+ }
73
+
74
+ /** The fields of a V3 refusal body its recipient acts on, as one line (post-review G5) — the owner's rule. */
75
+ function v3RefusalDetail(body) {
76
+ if (!body || typeof body !== "object") return "";
77
+ const out = [];
78
+ if (typeof body.remedy === "string" && body.remedy) out.push(body.remedy);
79
+ if (Array.isArray(body.allowed)) out.push(`Allowed now: ${body.allowed.length ? body.allowed.map(String).join(", ") : "nothing"}.`);
80
+ if (typeof body.objectionDeadline === "string" && body.objectionDeadline) out.push(`Objection deadline: ${body.objectionDeadline}.`);
81
+ if (typeof body.offerExpiresAt === "string" && body.offerExpiresAt) out.push(`Open offer expires: ${body.offerExpiresAt}.`);
82
+ return out.join(" ");
83
+ }
84
+
85
+ /**
86
+ * The line for a `POST /api/contracts/join` answer — same rule as `joinedLine` in
87
+ * src/lib/v3-client-text.ts (v3-client-text-parity.test.ts runs this copy against it).
88
+ */
89
+ function joinedLine(body) {
90
+ const activated = body && typeof body === "object" ? body.activated : undefined;
91
+ if (activated === true) return V3_CLIENT_TEXT.joined.active;
92
+ if (activated === false) return V3_CLIENT_TEXT.joined.awaitingOffer;
93
+ return V3_CLIENT_TEXT.joined.unknown;
94
+ }
95
+
13
96
  // ─── HTTP helpers ─────────────────────────────────────────────────────────────
14
97
  // Authorization header is omitted entirely when key is empty, rather than
15
98
  // sending a garbage "Bearer " token — matters for `verify-work`, the one
@@ -81,15 +164,20 @@ async function fetchJson(method, path, { json, form, key = API_KEY, deadlineMs =
81
164
  const retryAfterSeconds = retryAfterOf(res, data);
82
165
  const base = typeof data.error === "string" && data.error ? data.error : `HTTP ${res.status}`;
83
166
  // A platform error page on a POST (typically the 504 when a route hits its own
84
- // maxDuration — escrow-fund's is 120 s) does not mean the call failed: the function
85
- // may have moved money before it was killed. Say so, and how to check.
167
+ // maxDuration — contracts/review settles on chain inside its own) does not mean the
168
+ // call failed: the function may have moved money before it was killed. Say so, and
169
+ // how to check.
86
170
  const mayHaveCompleted = method !== "GET" && res.status >= 500 && data.code === "UPSTREAM_NON_JSON";
87
171
  throw Object.assign(
88
172
  new Error(
89
173
  `${base}${retryAfterSeconds !== null && (res.status === 429 || res.status === 503) ? ` — retry after ${retryAfterSeconds} s` : ""}${code ? ` [${code}]` : ""}` +
90
- (mayHaveCompleted ? ". The request may still have completed on the server — check the contract (cascrow get --contract <id>) before repeating it." : "")
174
+ (mayHaveCompleted ? ". The request may still have completed on the server — check the contract (cascrow get --contract <id>) before repeating it." : "") +
175
+ // A MilestoneEscrowV3 refusal says who can act and how (F-72): the body's actionable
176
+ // fields (G5), then the owner's words, role-aware on the body (H9).
177
+ (v3RefusalDetail(data) ? ` — ${v3RefusalDetail(data)}` : "") +
178
+ (v3HintFor(code, data) ? `\n\n${v3HintFor(code, data)}` : "")
91
179
  ),
92
- { status: res.status, code, retryAfterSeconds }
180
+ { status: res.status, code, retryAfterSeconds, body: data }
93
181
  );
94
182
  }
95
183
  return data;
@@ -273,15 +361,17 @@ async function waitForProof(proofId, maxMs = 100_000, key = API_KEY, pollMs = 5_
273
361
  }
274
362
  continue;
275
363
  }
276
- if (!last.pending || last.milestoneClosed === true) return { ...last, proofId };
364
+ // `notCompletableWithoutEscrow`: no verdict can complete this milestone — final.
365
+ if (!last.pending || last.milestoneClosed === true || last.notCompletableWithoutEscrow === true) return { ...last, proofId };
277
366
  // Parked for a human: no poll will change it (same rule as both MCP servers).
278
- if (last.running !== true && last.milestoneStatus === "PENDING_REVIEW") return { ...last, proofId, awaitingHumanReview: true };
367
+ // Never a MilestoneEscrowV3 proof (post-review L2): no human review decides one.
368
+ if (last.escrowVersion !== 3 && last.running !== true && last.milestoneStatus === "PENDING_REVIEW") return { ...last, proofId, awaitingHumanReview: true };
279
369
  if (last.claimPhase === "orphaned") return { ...last, proofId, stoppedBecause: "interrupted" };
280
370
  }
281
371
  return { ...(last ?? {}), pending: true, proofId, timedOut: true };
282
372
  }
283
373
 
284
- /** One line for a proof-status result, saying whether funds moved (they did only at COMPLETED). */
374
+ /** One line for a proof-status result, saying whether funds moved. */
285
375
  function statusSummary(s) {
286
376
  if (s.stoppedBecause === 404) return `⛔ Proof ${s.proofId} no longer exists (deleted or erased). Nothing will verify it.`;
287
377
  if (s.stoppedBecause === 401 || s.stoppedBecause === 403) return `⛔ Stopped: ${s.error}. Waiting does not change this — check CASCROW_API_KEY and that this account is a party to the contract.`;
@@ -293,20 +383,35 @@ function statusSummary(s) {
293
383
  if (s.awaitingHumanReview) return "⏳ PENDING_REVIEW — the panel reached no verdict; a human decides. Waiting will not change it.";
294
384
  if (s.timedOut) return `⏳ Still verifying. Check again: cascrow status --proof ${s.proofId} --wait`;
295
385
  const conf = typeof s.confidence === "number" ? ` (${s.confidence}% confidence)` : "";
386
+ // MilestoneEscrowV3 (post-review G4): the verdict is a RECOMMENDATION and moves no money.
387
+ // Asked before every v2 arm — the claim-relevant answer is the SUBMISSION's verdict
388
+ // (`submissionVerdict`, `claimReady`), never this proof's `decision`, and a closed or
389
+ // COMPLETED V3 milestone was settled on chain by the parties.
390
+ if (s.escrowVersion === 3 && !(s.milestoneStatus === "COMPLETED" || s.milestoneClosed === true)) {
391
+ const line = V3_CLIENT_TEXT.recommendation.replace("{verdict}", s.submissionVerdict ?? "PENDING").replace("{confidence}", String(s.confidence ?? 0));
392
+ return s.manifestHash ? `${line} manifestHash: ${s.manifestHash}` : line;
393
+ }
394
+ if (s.escrowVersion === 3) {
395
+ return s.milestoneStatus === "COMPLETED"
396
+ ? "✅ COMPLETED — the MilestoneEscrowV3 escrow paid out on chain."
397
+ : `⛔ CLOSED — the milestone is ${s.terminalStatus ?? "closed"}; the MilestoneEscrowV3 escrow settled on chain. Do not resubmit to it.`;
398
+ }
296
399
  // COMPLETED first: proof-status reports a paid milestone as `milestoneClosed` too
297
400
  // (terminalStatus COMPLETED), and the CLOSED arm below would read a release as
298
401
  // "moves no funds" — SEC-059's inversion (same order as proofOutcomeLine in both
299
402
  // MCP servers). Whether it was THIS proof's YES is `decision`.
300
403
  if (s.milestoneStatus === "COMPLETED" || s.terminalStatus === "COMPLETED") {
301
- return s.decision === "YES" ? `✅ COMPLETED${conf} — funds released.` : "✅ COMPLETED — funds released, but not on this proof's verdict (another proof or the funder's review approved it).";
404
+ return s.decision === "YES" ? `✅ COMPLETED${conf} — no money was held (verification-only contract).` : `✅ COMPLETED — no money was held (verification-only contract), but not on this proof's verdict (another proof or the funder's review approved it).`;
302
405
  }
303
406
  if (s.milestoneClosed === true) {
304
407
  return s.terminalReason === "RELEASED_ON_CHAIN"
305
408
  ? "⛔ CLOSED (EXPIRED) — the chain shows the builder WAS paid, just not by this verification."
306
409
  : `⛔ CLOSED — the milestone is ${s.terminalStatus ?? "closed"}; this verification moved no funds. Do not resubmit to it.`;
307
410
  }
308
- if (s.milestoneStatus === "VERIFIED" && s.releaseHoldUntil) return `⏸️ VERIFIED${conf} — payout scheduled for ${s.releaseHoldUntil}. NO FUNDS HAVE MOVED YET.`;
309
- if (s.milestoneStatus === "VERIFIED") return `⚠️ VERIFIED${conf} — approved, no funds moved yet.`;
411
+ // An approval cannot complete this milestone (an amount without a MilestoneEscrowV3
412
+ // escrow); the verdict doors answered 409 NOT_COMPLETABLE_WITHOUT_ESCROW.
413
+ if (s.notCompletableWithoutEscrow === true) return `⛔ NOT COMPLETABLE — ${V3_CLIENT_TEXT.codeHints.NOT_COMPLETABLE_WITHOUT_ESCROW}`;
414
+ if (s.milestoneStatus === "VERIFIED") return `⚠️ VERIFIED${conf} — approved, not completed yet.`;
310
415
  if (s.milestoneStatus === "PENDING_REVIEW") return `⏳ PENDING_REVIEW${conf} — manual review required.`;
311
416
  if (s.decision === "NO") return `❌ REJECTED${conf}.`;
312
417
  return `ℹ️ Decision: ${s.decision ?? "none"}${conf}, milestone ${s.milestoneStatus ?? "unknown"}.`;
@@ -322,6 +427,12 @@ function statusSummary(s) {
322
427
  */
323
428
  function outcomeLine(r) {
324
429
  const c = r.confidence ?? 0;
430
+ // MilestoneEscrowV3: a recommendation that moves nothing — asked first, so no arm
431
+ // below can read it as a payout or a rejection.
432
+ if (r.action === "V3_RECOMMENDATION") {
433
+ // The SUBMISSION's verdict, never the panel's `decision` on one file.
434
+ return V3_CLIENT_TEXT.recommendation.replace("{verdict}", r.submissionVerdict ?? "PENDING").replace("{confidence}", String(c));
435
+ }
325
436
  if (r.action === "SUPERSEDED") {
326
437
  // `settledAs`: the milestone was closed (e.g. EXPIRED) before this verification could
327
438
  // settle it, not beaten by a proof.
@@ -331,17 +442,19 @@ function outcomeLine(r) {
331
442
  }
332
443
  if (r.action === "COMPLETED") {
333
444
  return r.alreadyReleased === true
334
- ? "✅ COMPLETED — this milestone was already released by another request. The funds are out; do not resubmit."
335
- : `✅ COMPLETED (${c}% confidence) — funds released${r.txHash ? `, tx ${r.txHash}` : ""}.`;
445
+ ? `✅ COMPLETED — another request completed this milestone first; no money was held (verification-only contract). Do not resubmit.`
446
+ : `✅ COMPLETED (${c}% confidence) — no money was held (verification-only contract)${r.txHash ? `, tx ${r.txHash}` : ""}.`;
336
447
  }
337
448
  if (r.complianceBlocked === true) {
338
449
  return `⏸️ VERIFIED (${c}%) — payout on hold pending compliance review. No funds moved.`;
339
450
  }
340
- if (r.releaseHoldUntil) {
341
- return `⏸️ VERIFIED (${c}% confidence) — approved, payout scheduled for ${r.releaseHoldUntil}. NO FUNDS HAVE MOVED YET: the funder can contest until then, and the date can move.`;
451
+ if (r.action === "NOT_COMPLETABLE_WITHOUT_ESCROW") {
452
+ return `⛔ NOT COMPLETED (${c}% confidence) — ${V3_CLIENT_TEXT.codeHints.NOT_COMPLETABLE_WITHOUT_ESCROW}`;
342
453
  }
343
454
  if (r.action === "VERIFIED") {
344
- return `⚠️ VERIFIED (${c}% confidence) — the work was approved, but the automatic payout did NOT go through. No funds have moved; the release can be retried.`;
455
+ // Approved, but no completion followed; the producer does not reach this since
456
+ // Phase 6. No retry is promised — there is nothing to retry.
457
+ return `⚠️ VERIFIED (${c}% confidence) — the work was approved, but the milestone was NOT completed. No money moved. Check it with \`cascrow status\`; if it stays VERIFIED, contact support@cascrow.com.`;
345
458
  }
346
459
  if (r.timedOut === true) {
347
460
  return "⏳ PENDING_REVIEW — the verification panel did not answer (too few models responded). This is NOT a rejection and NOT a judgement of the work; a human decides.";
@@ -423,7 +536,7 @@ function err(msg) {
423
536
 
424
537
  function usage() {
425
538
  console.log(`
426
- Cascrow CLI — agentic escrow and verification
539
+ cascrow CLI — AI verification, on-chain reputation and non-custodial escrow for agents
427
540
 
428
541
  Usage:
429
542
  cascrow <command> [options]
@@ -433,8 +546,6 @@ Commands:
433
546
  verify-work Verify AI-generated work — no contract, no escrow, no account needed
434
547
  verify Submit proof and get AI verdict in one call (recommended)
435
548
  create Create a milestone contract
436
- fund Activate a milestone (simulation — no on-chain tx)
437
- escrow-fund Fund a milestone with real on-chain RLUSD escrow
438
549
  submit Upload proof text — verification starts by itself (then: status --proof <id> --wait)
439
550
  status Show (or --wait for) the verification result of a proof
440
551
  get Get contract status and milestones
@@ -443,6 +554,11 @@ Commands:
443
554
  check-invites Check pending contract invites for this agent
444
555
  review-queue As funder: list milestones still waiting for your verdict
445
556
  review As funder: approve or reject a milestone under manual review
557
+ human-review As funder or builder: ask a person at cascrow to review a verification outcome (Art. 22(3) GDPR), or --list the requests
558
+ v3-claims As payer: list MilestoneEscrowV3 claims you can still object to on chain
559
+ v3 <sub> MilestoneEscrowV3, remote half: offer | acceptance | accept-submit | prepare | report-tx | attestation | claims
560
+ and "v3 sign <cascrow-signer command…>" — runs the pinned local signer (npx cascrow-signer@0.2.0),
561
+ which holds your key on YOUR machine. cascrow never signs, sends or relays for you.
446
562
  me Print this agent's ID
447
563
 
448
564
  Options for each command:
@@ -469,21 +585,18 @@ Options for each command:
469
585
  create
470
586
  --title <title> required short milestone title (printed on the certificate)
471
587
  --days <n> optional deadline days from now (default: 7)
472
- --amount <usd> optional RLUSD to lock in escrow (default: 0 = verification-only)
588
+ --amount <usd> optional default 0 = verification-only (active once the builder joins). Above 0 the
589
+ milestone is a MilestoneEscrowV3 escrow in native XRP: offer it with
590
+ "cascrow v3 offer", the builder accepts, you fund with "cascrow v3 sign fund"
473
591
  --criteria <text> optional what the AI panel checks the proof against (max 4000 chars)
474
592
  --criteria-file <path> optional the same, read from a file
475
593
  --checklist-file <path> optional JSON array of { title, severity?, id? } (max 20) scored item by item on text proofs
476
594
  --review-mode <mode> optional AUTO | MANUAL | MANUAL_AUTO — what happens when the panel is unsure (60–85%)
477
-
478
- fund
479
- --contract <id> required
480
- --milestone <id> optional defaults to first available
481
-
482
- escrow-fund [REAL on-chain RLUSD — agent's own wallet]
483
- --contract <id> required
484
- --private-key <key> required EVM private key (funds come from this wallet)
485
- --amount <usd> IGNORED the escrowed amount is the milestone's own amountUSD
486
- --milestone <id> optional
595
+ --review-days <n> optional your review window, 2–30 days (default 14; under 7 only if both parties
596
+ registered as businesses). Without your decision a milestone completes
597
+ only if the panel's own verdict was YES; otherwise cascrow's human review decides
598
+ --panel-alone optional the panel decides alone: YES above 85 % completes, anything else is a
599
+ rejection the builder can resubmit against — you do not review
487
600
 
488
601
  submit [or use verify for text proof in one call]
489
602
  --milestone <id> required
@@ -522,15 +635,13 @@ Examples:
522
635
  # Register a new agent account (no human in the loop)
523
636
  cascrow register --email bot@example.com --password secret123
524
637
 
525
- # One-shot: create contract, fund it, then verify a code fix
638
+ # One-shot: create a verification-only contract (active once the builder joins), then verify a code fix
526
639
  cascrow create --title "Fix auth bug" --criteria "All tests pass; JWT expiry honoured; no new lint errors" --days 7
527
- cascrow fund --contract <contractId>
528
640
  cascrow verify --contract <contractId> --proof "Fixed JWT expiry bug in auth.ts, 42 tests green, PR #51 merged" --commit abc1234 --links https://github.com/you/repo/pull/51
529
641
 
530
- # Real escrow flow (RLUSD locked on-chain)
531
- cascrow create --title "Deploy API" --amount 100 --days 14
532
- cascrow escrow-fund --contract <contractId> --private-key 0x...
533
- cascrow verify --contract <contractId> --proof "API live at https://api.example.com, all endpoints responding"
642
+ # Paid work is a MilestoneEscrowV3 escrow (native XRP, testnet): create with --amount,
643
+ # offer it (cascrow v3 offer), the builder accepts, and you fund with one transaction
644
+ # from your own machine (cascrow v3 sign fund). This CLI never takes a key.
534
645
 
535
646
  # Agent-to-agent handoff
536
647
  cascrow me # → get your Agent ID
@@ -542,10 +653,30 @@ Examples:
542
653
 
543
654
  # As the funder: decide what the AI panel could not (confidence 60-85)
544
655
  # Poll this — a manual_review.required webhook you missed is a decision nobody
545
- # makes, and cascrow auto-approves an undecided milestone after 48h.
656
+ # makes; an undecided milestone completes automatically when its review window
657
+ # closes only when the panel's own YES opened the review and the contract's terms let
658
+ # silence count. Any other review goes to cascrow's human review.
659
+ # Verification-only contracts: a verdict completes or hands back, and moves no money.
546
660
  cascrow review-queue
547
661
  cascrow review --contract <contractId> --decision APPROVE
548
662
  cascrow review --contract <contractId> --decision REJECT --milestone <milestoneId>
663
+
664
+ # Either party: a person at cascrow reviews a verification outcome (decided within a month)
665
+ cascrow human-review --contract <contractId> [--milestone <milestoneId>] [--reason "why"]
666
+ cascrow human-review --contract <contractId> --list
667
+
668
+ # As the payer of a MilestoneEscrowV3 escrow: claims release unless YOUR wallet
669
+ # calls object(escrowId) before the deadline — cascrow cannot object for you.
670
+ cascrow v3-claims
671
+
672
+ # MilestoneEscrowV3 (native XRP). cascrow prepares and reads; you sign locally.
673
+ cascrow v3 offer --milestone <id> --review-period-sec 259200 [--watcher] [--arbiter 0x… --arbitration-period-sec N]
674
+ cascrow v3 acceptance --milestone <id> # as builder: the frozen terms to check
675
+ cascrow v3 sign accept --milestone <id> --network testnet # check + sign locally (pinned cascrow-signer)
676
+ cascrow v3 sign fund --milestone <id> --network testnet # as payer: one transaction, value = amount
677
+ cascrow v3 prepare --milestone <id> --action object --reason-hash 0x… # UNSIGNED — verify before signing
678
+ cascrow v3 report-tx --milestone <id> --tx 0x<64 hex> # a HASH only, never a signed transaction
679
+ cascrow v3 attestation --escrow 0x… --claim-index 1 # non-binding AI recommendation
549
680
  `);
550
681
  process.exit(0);
551
682
  }
@@ -672,7 +803,7 @@ async function cmdVerifyWork(args) {
672
803
  }
673
804
  }
674
805
 
675
- process.stderr.write("\nVerifying with 5 independent AI models (no escrow, no account needed)...\n\n");
806
+ process.stderr.write("\nVerifying with 5 AI models run by 5 different providers (no escrow, no account needed)...\n\n");
676
807
 
677
808
  let data;
678
809
  if (args.image) {
@@ -747,7 +878,8 @@ async function cmdVerifyWork(args) {
747
878
  }
748
879
 
749
880
  // One-shot verify: submit text proof + get verdict in a single call via /api/mcp/submit.
750
- // Auto-releases funds and mints NFT on approval — no separate `verify --proof <id>` needed.
881
+ // A verification-only milestone completes (NFT minted) on approval; a MilestoneEscrowV3
882
+ // verdict is a recommendation that moves no money. No separate `verify --proof <id>` needed.
751
883
  async function cmdVerify(args) {
752
884
  if (!API_KEY) err("CASCROW_API_KEY is not set");
753
885
  if (!args.proof) err("--proof is required");
@@ -792,9 +924,12 @@ async function cmdVerify(args) {
792
924
  // nothing moved, do not resubmit" from "another proof won" (round-3 pass 2).
793
925
  settledAs: typeof result.settledAs === "string" ? result.settledAs : null,
794
926
  complianceBlocked: result.complianceBlocked === true,
795
- releaseHoldUntil: result.releaseHoldUntil ?? null,
927
+ notCompletableWithoutEscrow: result.action === "NOT_COMPLETABLE_WITHOUT_ESCROW",
796
928
  panelUnavailable: result.timedOut === true,
797
929
  txHash: result.txHash ?? null,
930
+ // MilestoneEscrowV3 (post-review G4): the recommendation's own fields, so a machine
931
+ // caller reads the SUBMISSION's verdict and the hash to claim with.
932
+ ...(result.action === "V3_RECOMMENDATION" || result.escrowVersion === 3 ? v3Fields(result) : {}),
798
933
  reasoning: result.reasoning,
799
934
  modelVotes: (result.votes ?? []).map(v => ({ model: v.model, decision: v.decision, confidence: v.confidence })),
800
935
  });
@@ -825,14 +960,17 @@ async function cmdVerify(args) {
825
960
  // `verdict` key, so `data.verdict ?? "unknown"` fell to the final arm below and told
826
961
  // the builder "❌ REJECTED — resubmit with stronger proof" for a milestone that may
827
962
  // well have been approved and paid (SEC-055). Classified before the verdict is read.
828
- const verdict = data.decided === "elsewhere"
963
+ const isV3 = data.action === "V3_RECOMMENDATION";
964
+ const verdict = isV3
965
+ ? "v3_recommendation"
966
+ : data.decided === "elsewhere"
829
967
  ? "decided_elsewhere"
830
968
  : data.panelUnavailable
831
969
  ? "panel_unavailable"
832
970
  : (data.verdict ?? "unknown");
833
971
  const confidence = data.confidence ?? 0;
834
- // Approved, payout SCHEDULED, funds still in escrow so the funder can contest.
835
- const releaseHoldUntil = data.release_hold_until ?? null;
972
+ // An approved milestone that cannot complete is not a 200: the route answers 409
973
+ // NOT_COMPLETABLE_WITHOUT_ESCROW, which `apiPost` turns into an error with its hint.
836
974
 
837
975
  // Print model votes if available
838
976
  const votes = Array.isArray(data.model_votes) ? data.model_votes : [];
@@ -848,7 +986,12 @@ async function cmdVerify(args) {
848
986
  const settledAs = verdict === "decided_elsewhere" && typeof data.settledAs === "string" ? data.settledAs : null;
849
987
 
850
988
  let statusLine;
851
- if (settledAs) {
989
+ if (isV3) {
990
+ // A MilestoneEscrowV3 submission: sealed, recommended on, nothing moved (G4).
991
+ statusLine = V3_CLIENT_TEXT.recommendation.replace("{verdict}", data.submissionVerdict ?? "PENDING").replace("{confidence}", String(confidence));
992
+ if (data.manifestHash) statusLine += `\n manifestHash: ${data.manifestHash}`;
993
+ if (typeof data.next === "string") statusLine += `\n ${data.next}`;
994
+ } else if (settledAs) {
852
995
  statusLine =
853
996
  `ℹ️ This milestone was closed (${settledAs}) before this submission could settle it, so this submission ` +
854
997
  "changed nothing and moved no funds itself. Your evidence was recorded. Do not resubmit to this milestone";
@@ -862,10 +1005,6 @@ async function cmdVerify(args) {
862
1005
  "⏳ PENDING_REVIEW — the verification panel could not reach a verdict (too few models " +
863
1006
  "answered). This is NOT a rejection and NOT a judgement of your work; a human decides, " +
864
1007
  "and resubmitting the same evidence will not help while the panel is down";
865
- } else if (verdict === "approved" && releaseHoldUntil) {
866
- statusLine =
867
- `⏸️ APPROVED (${confidence}% confidence) — payout scheduled for ${releaseHoldUntil}. ` +
868
- "NO FUNDS HAVE MOVED YET: the funder can contest until then, and the date can move";
869
1008
  } else if (verdict === "approved") {
870
1009
  statusLine = `✅ VERIFIED (${confidence}% confidence)`;
871
1010
  if (data.on_chain_url) statusLine += `\n Proof: ${data.on_chain_url}`;
@@ -886,18 +1025,28 @@ async function cmdVerify(args) {
886
1025
  print({
887
1026
  verdict,
888
1027
  confidence,
889
- // MONEY MOVED — not "the verdict was approved". A scheduled payout has not happened.
890
- passed: verdict === "approved" && !releaseHoldUntil,
891
- release_hold_until: releaseHoldUntil,
1028
+ passed: verdict === "approved",
892
1029
  ...(settledAs ? { settled_as: settledAs } : {}),
893
1030
  reasoning: data.reasoning ?? "",
894
1031
  on_chain_url: data.on_chain_url ?? null,
895
1032
  proof_id: data.proof_id ?? null,
896
1033
  signed_at: data.signed_at ?? null,
1034
+ ...(isV3 ? v3Fields(data) : {}),
897
1035
  model_votes: votes,
898
1036
  });
899
1037
  }
900
1038
 
1039
+ /** The MilestoneEscrowV3 fields of an answer, for JSON output (post-review G4). Never derived: forwarded. */
1040
+ function v3Fields(r) {
1041
+ return {
1042
+ escrowVersion: 3,
1043
+ submissionId: r.submissionId ?? null,
1044
+ manifestHash: r.manifestHash ?? null,
1045
+ submissionVerdict: r.submissionVerdict ?? "PENDING",
1046
+ claimReady: r.claimReady === true,
1047
+ };
1048
+ }
1049
+
901
1050
  async function cmdCreate(args) {
902
1051
  if (!API_KEY) err("CASCROW_API_KEY is not set");
903
1052
  if (!args.title) err("--title is required");
@@ -924,6 +1073,10 @@ async function cmdCreate(args) {
924
1073
  if (reviewMode !== undefined && !["AUTO", "MANUAL", "MANUAL_AUTO"].includes(reviewMode)) {
925
1074
  err("--review-mode must be AUTO, MANUAL or MANUAL_AUTO");
926
1075
  }
1076
+ // The range is the server's (review-terms.ts); only a non-number is refused here.
1077
+ const reviewDays = args["review-days"] !== undefined ? Number(args["review-days"]) : undefined;
1078
+ if (reviewDays !== undefined && !Number.isInteger(reviewDays)) err("--review-days must be a whole number of days");
1079
+ const panelAlone = args["panel-alone"] === true || args["panel-alone"] === "true" ? true : undefined;
927
1080
  let criteria, checklist;
928
1081
  try {
929
1082
  criteria = normalizeCriteria(criteriaRaw);
@@ -941,6 +1094,8 @@ async function cmdCreate(args) {
941
1094
  ...(checklist !== undefined ? { verificationChecklist: checklist } : {}),
942
1095
  }],
943
1096
  ...(reviewMode !== undefined ? { agentReviewMode: reviewMode } : {}),
1097
+ ...(reviewDays !== undefined ? { reviewWindowDays: reviewDays } : {}),
1098
+ ...(panelAlone !== undefined ? { panelDecidesAlone: panelAlone } : {}),
944
1099
  });
945
1100
 
946
1101
  const contractUrl = `${BASE_URL}/contract/${data.contractId}`;
@@ -948,42 +1103,28 @@ async function cmdCreate(args) {
948
1103
  process.stderr.write(`\nContract created: ${contractUrl}\n\n`);
949
1104
  }
950
1105
 
951
- async function cmdFund(args) {
952
- if (!API_KEY) err("CASCROW_API_KEY is not set");
953
- if (!args.contract) err("--contract is required");
954
-
955
- const body = args.milestone
956
- ? { contractId: args.contract, milestoneId: args.milestone }
957
- : { contractId: args.contract };
958
-
959
- const data = await apiPost("/api/agent/fund-milestone", body);
960
- print(data);
961
- process.stderr.write(`\nMilestone funded (simulation). ID: ${data.milestoneId}\n\n`);
1106
+ // Removed in 3.0.0 (cascrow Phase 6, owner decision 16): `fund` activated a v2
1107
+ // milestone. A $0 verification contract is active by itself once the builder has
1108
+ // joined, and a milestone with an amount is a MilestoneEscrowV3 escrow its payer
1109
+ // funds from their own machine. Kept as a command only to say so; it sends nothing.
1110
+ function cmdFundRemoved() {
1111
+ err(`${V3_CLIENT_TEXT.fundRemoved} [TOOL_REMOVED]`);
962
1112
  }
963
1113
 
964
- async function cmdEscrowFund(args) {
965
- if (!API_KEY) err("CASCROW_API_KEY is not set");
966
- if (!args.contract) err("--contract is required");
967
- if (!args["private-key"]) err("--private-key is required (EVM private key, funds come from this wallet)");
968
-
969
- const body = {
970
- contractId: args.contract,
971
- agentPrivateKey: args["private-key"],
972
- };
973
- if (args.milestone) body.milestoneId = args.milestone;
974
- if (args.amount) {
975
- // NOT forwarded. `agent/escrow-fund` funds `milestone.amountUSD` and ignores
976
- // a body field, so `--amount` never overrode anything — it only told the
977
- // user it had. Say so rather than sending a number the server drops.
978
- process.stderr.write(
979
- "\n⚠️ --amount is ignored for escrow funding: the escrowed amount is the milestone's own amountUSD.\n"
980
- );
981
- }
982
-
983
- process.stderr.write("\nFunding milestone on-chain (approve + fundMilestone)...\n");
984
- const data = await apiPost("/api/agent/escrow-fund", body);
985
- print(data);
986
- process.stderr.write(`\n✅ ${data.amountUSD} RLUSD locked on-chain. TX: ${data.txHash}\n\n`);
1114
+ // Removed in 2.0.0 (cascrow Phase 5): `escrow-fund --private-key` sent the key
1115
+ // to cascrow, which signed the deposit server-side. Kept as a command only to say
1116
+ // so: it sends NOTHING — no request, no argument — so a key typed on the command
1117
+ // line goes nowhere. It cannot un-type it, which is why the message tells the user
1118
+ // what a key that reached a shell history (or an older version of this CLI) is now.
1119
+ function cmdEscrowFundRemoved() {
1120
+ err(
1121
+ "escrow-fund was removed in cascrow-cli 2.0.0: cascrow never accepts, creates, stores or signs with private keys. " +
1122
+ "A $0 verification contract needs no funding — it is active once the builder has joined. A milestone with an amount " +
1123
+ "is a MilestoneEscrowV3 escrow in native XRP, funded by the payer with one transaction from their own machine: first link " +
1124
+ "the wallet you will fund from (GET /api/user/wallet/challenge?address=<your wallet>, sign the message with that wallet, POST /api/user/wallet), then run " +
1125
+ "npx cascrow-signer@0.2.0 fund --milestone <milestoneId>. If you ever passed a private key to this command (it is also in your shell history), " +
1126
+ "treat that key as exposed and move its funds to a new wallet. [CUSTODY_PATH_REMOVED]"
1127
+ );
987
1128
  }
988
1129
 
989
1130
  // Legacy two-step: upload proof → returns proofId → user runs `cascrow verify --proof <id>`
@@ -1031,7 +1172,8 @@ async function cmdJoin(args) {
1031
1172
  if (!args.invite) err("--invite is required");
1032
1173
  const data = await apiPost("/api/contracts/join", { inviteCode: args.invite });
1033
1174
  print(data);
1034
- process.stderr.write(`\nJoined contract: ${data.contractId}\n\n`);
1175
+ // `activated`: a $0 contract is active now; a money contract waits for its V3 offer.
1176
+ process.stderr.write(`\nJoined contract: ${data.contractId}\n${joinedLine(data)}\n\n`);
1035
1177
  }
1036
1178
 
1037
1179
  async function cmdHandoff(args) {
@@ -1071,9 +1213,9 @@ async function cmdCheckInvites() {
1071
1213
  * verdict on cannot come apart (F-22).
1072
1214
  *
1073
1215
  * `review-queue` is the pull behind the `manual_review.required` webhook: a
1074
- * webhook nobody received is a decision nobody makes, and cascrow auto-approves
1075
- * an undecided milestone after 48 h (agent contracts) or 14 days (human) and
1076
- * releases the escrow. `review` ships with it rather than later, because a list
1216
+ * webhook nobody received is a decision nobody makes, and cascrow completes an
1217
+ * undecided milestone after 48 h (agent contracts in MANUAL_AUTO/AUTO) or 14 days
1218
+ * (every other contract). Verification-only contracts: no money moves. `review` ships with it rather than later, because a list
1077
1219
  * its reader cannot act on names a remedy they are unable to perform (F-72).
1078
1220
  */
1079
1221
  async function cmdReviewQueue() {
@@ -1081,8 +1223,16 @@ async function cmdReviewQueue() {
1081
1223
  const data = await apiGet("/api/agent/review-queue");
1082
1224
  const contracts = data.contracts ?? [];
1083
1225
  print(data);
1226
+ // Said in both branches: an empty v2 queue is exactly when a reader stops
1227
+ // looking, and a V3 claim window keeps running.
1228
+ const v3 =
1229
+ data.v3ClaimsPending === null
1230
+ ? "Open MilestoneEscrowV3 claims could not be counted — run: cascrow v3-claims\n\n"
1231
+ : data.v3ClaimsPending > 0
1232
+ ? `${data.v3ClaimsPending} MilestoneEscrowV3 claim(s) can still be objected to — run: cascrow v3-claims\n\n`
1233
+ : "";
1084
1234
  if (contracts.length === 0) {
1085
- process.stderr.write("\nNo milestones are waiting for your verdict.\n\n");
1235
+ process.stderr.write(`\nNo milestones are waiting for your verdict.\n\n${v3}`);
1086
1236
  return;
1087
1237
  }
1088
1238
  const lines = contracts.map((c) => {
@@ -1093,15 +1243,100 @@ async function cmdReviewQueue() {
1093
1243
  : pending.length > 1
1094
1244
  ? `${pending.length} milestones pending — pass --milestone <id>`
1095
1245
  : "contract-level review — omit --milestone";
1096
- return ` ${c.contractId} ${c.amountUSD} RLUSD (${ms})`;
1246
+ return ` ${c.contractId} (${ms})`;
1097
1247
  }).join("\n");
1098
1248
  const more = data.truncated ? `\n(${data.total} in total — this list is capped.)` : "";
1099
1249
  process.stderr.write(
1100
1250
  `\n${data.total} contract(s) waiting for your verdict:\n${lines}${more}\n\n` +
1101
- "Decide with: cascrow review --contract <id> --decision APPROVE|REJECT [--milestone <id>]\n\n"
1251
+ "Decide with: cascrow review --contract <id> --decision APPROVE|REJECT [--milestone <id>]\n\n" +
1252
+ v3
1102
1253
  );
1103
1254
  }
1104
1255
 
1256
+ /**
1257
+ * The payer's open MilestoneEscrowV3 claims — `GET /api/agent/v3-claims`, the
1258
+ * pull behind the `claim.submitted` webhook. Print only: the action is the
1259
+ * payer's own `object(escrowId)` on chain, which cascrow never performs.
1260
+ */
1261
+ async function cmdV3Claims() {
1262
+ if (!API_KEY) err("CASCROW_API_KEY is not set");
1263
+ const data = await apiGet("/api/agent/v3-claims");
1264
+ const claims = data.claims ?? [];
1265
+ print(data);
1266
+ if (claims.length === 0) {
1267
+ process.stderr.write("\nNo MilestoneEscrowV3 claim is waiting on you.\n\n");
1268
+ return;
1269
+ }
1270
+ const lines = claims.map((c) =>
1271
+ ` ${c.escrowId} on ${c.chainId}/${c.escrowContract} claim ${c.claimIndex}` +
1272
+ `${c.secondFunding ? " SECOND funding" : ""}${c.held ? " (held — verify on chain)" : ""} ` +
1273
+ (c.objectable === null ? "claim state unknown here — read getEscrow on chain" : c.objectable ? `object() before ${c.objectionDeadline}` : `window closed at ${c.objectionDeadline}`)
1274
+ ).join("\n");
1275
+ const more = data.truncated ? "\n(This list is capped; more claims exist.)" : "";
1276
+ process.stderr.write(`\n${claims.length} claim(s):\n${lines}${more}\n\ncascrow cannot object for you — your own wallet calls object(escrowId) on the escrow contract.\n\n`);
1277
+ }
1278
+
1279
+ // ─── MilestoneEscrowV3, remote half (design Rev. 10) ──────────────────────────
1280
+ // cascrow prepares and reads; the party signs and sends with its own key. This CLI
1281
+ // holds no key and has no dependency: `v3 sign …` runs the PINNED local signer
1282
+ // (V3_CLIENT_TEXT.signerPin) in a child process, with this terminal attached, so
1283
+ // its prompts and its checks are the signer's own.
1284
+ async function cmdV3(sub, args, rawArgs) {
1285
+ if (sub === "sign") {
1286
+ const child = spawn("npx", ["--yes", V3_CLIENT_TEXT.signerPin, ...rawArgs], { stdio: "inherit", shell: process.platform === "win32" });
1287
+ const code = await new Promise((resolve) => child.on("close", resolve));
1288
+ process.exit(typeof code === "number" ? code : 1);
1289
+ }
1290
+ if (!API_KEY) err("CASCROW_API_KEY is not set");
1291
+ switch (sub) {
1292
+ case "offer": {
1293
+ if (!args.milestone || !args["review-period-sec"]) err("--milestone and --review-period-sec are required");
1294
+ const body = {
1295
+ milestoneId: args.milestone,
1296
+ reviewPeriodSec: Number(args["review-period-sec"]),
1297
+ watcherDeclared: args.watcher === true || args.watcher === "true",
1298
+ ...(args.arbiter ? { arbiter: args.arbiter } : {}),
1299
+ ...(args["arbitration-period-sec"] ? { arbitrationPeriodSec: Number(args["arbitration-period-sec"]) } : {}),
1300
+ };
1301
+ print(await apiPost("/api/escrow/v3/offer", body));
1302
+ process.stderr.write(`\nOffer sent. After the builder accepts, fund it from your machine: cascrow v3 sign fund --milestone ${args.milestone}\n\n`);
1303
+ return;
1304
+ }
1305
+ case "acceptance":
1306
+ if (!args.milestone) err("--milestone is required");
1307
+ print(await apiGet(`/api/escrow/v3/acceptance?milestoneId=${encodeURIComponent(args.milestone)}`));
1308
+ process.stderr.write("\nCheck every term — your signature binds you until acceptBy. To check and sign locally: cascrow v3 sign accept --milestone " + args.milestone + "\n\n");
1309
+ return;
1310
+ case "accept-submit":
1311
+ if (!args.offer || !args.signature) err("--offer and --signature are required");
1312
+ print(await apiPost("/api/escrow/v3/acceptance", { offerId: args.offer, signature: args.signature }));
1313
+ return;
1314
+ case "prepare": {
1315
+ if (!args.milestone || !args.action) err("--milestone and --action are required");
1316
+ const q = new URLSearchParams({ milestoneId: args.milestone, action: args.action });
1317
+ if (args["proof-hash"]) q.set("proofHash", args["proof-hash"]);
1318
+ if (args["reason-hash"]) q.set("reasonHash", args["reason-hash"]);
1319
+ if (args["new-deadline"]) q.set("newDeadline", String(args["new-deadline"]));
1320
+ print(await apiGet(`/api/escrow/v3/prepare?${q.toString()}`));
1321
+ process.stderr.write("\nUNSIGNED — cascrow does not sign, send or relay it. Rebuild data from the ABI and check to/chainId against your own configuration before signing (cascrow v3 sign … does).\n\n");
1322
+ return;
1323
+ }
1324
+ case "report-tx":
1325
+ if (!args.milestone || typeof args.tx !== "string" || !TX_HASH_RE.test(args.tx)) err(V3_CLIENT_TEXT.reportTxOnlyHash);
1326
+ print(await apiPost("/api/escrow/v3/sync", { milestoneId: args.milestone, txHash: args.tx }));
1327
+ return;
1328
+ case "attestation":
1329
+ if (!args.escrow || !args["claim-index"]) err("--escrow and --claim-index are required");
1330
+ print(await apiGet(`/api/escrow/v3/attestation?${new URLSearchParams({ escrowId: args.escrow, claimIndex: String(args["claim-index"]) }).toString()}`));
1331
+ return;
1332
+ case "claims":
1333
+ await cmdV3Claims();
1334
+ return;
1335
+ default:
1336
+ err(`unknown v3 command ${sub ?? "(none)"} — offer | acceptance | accept-submit | prepare | report-tx | attestation | claims | sign`);
1337
+ }
1338
+ }
1339
+
1105
1340
  async function cmdReview(args) {
1106
1341
  if (!API_KEY) err("CASCROW_API_KEY is not set");
1107
1342
  const contractId = args.contract;
@@ -1111,11 +1346,50 @@ async function cmdReview(args) {
1111
1346
  const body = args.milestone ? { contractId, decision, milestoneId: args.milestone } : { contractId, decision };
1112
1347
  const data = await apiPost("/api/contracts/review", body);
1113
1348
  print(data);
1349
+ // The route answers `{ status }` and nothing else: a review only decides
1350
+ // verification-only ($0) milestones, and nothing moves money (a MilestoneEscrowV3
1351
+ // milestone is refused — its payer releases or objects on chain).
1114
1352
  const what =
1115
1353
  decision === "APPROVE"
1116
- ? "approved — the escrow is being released to the builder"
1354
+ ? "approved and completed (a verification-only contract: no money moves)"
1117
1355
  : "rejected — the milestone is back with the builder, who can resubmit";
1118
- process.stderr.write(`\nMilestone ${what}.${data.txHash ? `\nRelease tx: ${data.txHash}` : ""}\n\n`);
1356
+ process.stderr.write(`\nMilestone ${what}.${data.status ? ` Contract status: ${data.status}.` : ""}\n\n`);
1357
+ }
1358
+
1359
+ /**
1360
+ * `cascrow human-review` — the request (POST /api/human-review) or, with --list, the
1361
+ * requests on a contract with their decisions (vp1-5, 2026-10-20). A request already
1362
+ * open on that milestone is not an error: it prints the deadline and exits 0.
1363
+ */
1364
+ async function cmdHumanReview(args) {
1365
+ if (!API_KEY) err("CASCROW_API_KEY is not set");
1366
+ const contractId = args.contract;
1367
+ if (!contractId) err("--contract <id> is required");
1368
+ if (args.list) {
1369
+ const data = await apiGet(`/api/human-review?contractId=${encodeURIComponent(contractId)}`);
1370
+ print(data);
1371
+ const requests = Array.isArray(data.requests) ? data.requests : [];
1372
+ process.stderr.write(
1373
+ requests.length === 0
1374
+ ? "\nNo human-review requests on this contract.\n\n"
1375
+ : `\n${requests.map((r) => ` ${r.id} [${r.status}] ${r.outcome ? `outcome ${r.outcome}` : `decision due by ${r.dueAt}`}`).join("\n")}\n\n`
1376
+ );
1377
+ return;
1378
+ }
1379
+ const body = { contractId, ...(args.milestone ? { milestoneId: args.milestone } : {}), ...(typeof args.reason === "string" && args.reason ? { reason: args.reason } : {}) };
1380
+ try {
1381
+ const data = await apiPost("/api/human-review", body);
1382
+ print(data);
1383
+ process.stderr.write(`\nHuman review requested (${data.requestId}). A person at cascrow decides by ${data.dueAt}; both parties receive the decision with its reasons.\n\n`);
1384
+ } catch (e) {
1385
+ if (e && e.code === "HUMAN_REVIEW_ALREADY_OPEN") {
1386
+ const dueAt = e.body && typeof e.body.dueAt === "string" ? e.body.dueAt : null;
1387
+ print({ status: "ALREADY_OPEN", dueAt });
1388
+ process.stderr.write(`\nA human review of this milestone is already open${dueAt ? `; a person at cascrow decides by ${dueAt}` : ""}. No need to ask again.\n\n`);
1389
+ return;
1390
+ }
1391
+ throw e;
1392
+ }
1119
1393
  }
1120
1394
 
1121
1395
  async function cmdMe() {
@@ -1140,8 +1414,8 @@ try {
1140
1414
  case "verify-work": await cmdVerifyWork(args); break;
1141
1415
  case "verify": await cmdVerify(args); break;
1142
1416
  case "create": await cmdCreate(args); break;
1143
- case "fund": await cmdFund(args); break;
1144
- case "escrow-fund": await cmdEscrowFund(args); break;
1417
+ case "fund": cmdFundRemoved(); break;
1418
+ case "escrow-fund": cmdEscrowFundRemoved(); break;
1145
1419
  case "submit": await cmdSubmit(args); break;
1146
1420
  case "status": await cmdStatus(args); break;
1147
1421
  case "get": await cmdGet(args); break;
@@ -1150,6 +1424,9 @@ try {
1150
1424
  case "check-invites": await cmdCheckInvites(); break;
1151
1425
  case "review-queue": await cmdReviewQueue(); break;
1152
1426
  case "review": await cmdReview(args); break;
1427
+ case "human-review": await cmdHumanReview(args); break;
1428
+ case "v3-claims": await cmdV3Claims(); break;
1429
+ case "v3": await cmdV3(rest[0], parseArgs(rest.slice(1)), rest.slice(1)); break;
1153
1430
  case "me": await cmdMe(); break;
1154
1431
  default:
1155
1432
  console.error(`Unknown command: ${command}\n`);