cascrow-cli 1.3.0 → 3.0.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,91 @@
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.1.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.1.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
+ "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.",
33
+ "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.",
34
+ "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.1.0 fund --milestone <milestoneId>) or the offer or acceptance lapses.",
35
+ "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.",
36
+ "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.",
37
+ "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.1.0.",
38
+ "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.",
39
+ "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.1.0 accept does it and checks them first.",
40
+ "V3_INDEX_STALE": "cascrow's index of the chain is behind. Read the escrow from the chain directly (npx cascrow-signer@0.1.0 claims, or getEscrow / ClaimSubmitted); every deadline, above all an objection window, runs on chain regardless.",
41
+ "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.",
42
+ "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.",
43
+ "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.",
44
+ "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.",
45
+ "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.",
46
+ "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."
47
+ },
48
+ "joined": {
49
+ "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.",
50
+ "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.1.0 accept --milestone <milestoneId>, or on the contract page. cascrow never signs for you. Submit proof once the payer has funded the milestone.",
51
+ "unknown": "Joined as Builder. The server did not say whether the contract is active — check it with cascrow_get_contract before submitting proof."
52
+ },
53
+ "payeeHints": {
54
+ "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.1.0 submit-claim --milestone <milestoneId> --proof-hash <manifestHash>."
55
+ },
56
+ "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.1.0 fund --milestone <milestoneId>. cascrow never signs or sends for you.",
57
+ "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.1.0 submit-claim --milestone <milestoneId> --proof-hash <manifestHash>). As payer: release, or object before the objection deadline.",
58
+ "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."
59
+ };
60
+ // END v3-client-text
61
+ const TX_HASH_RE = /^0x[0-9a-fA-F]{64}$/;
62
+ function v3HintFor(code, body) {
63
+ if (!code) return null;
64
+ // Role-aware (post-review H9): CLAIM_PROOF_UNKNOWN without objectionDeadline comes from
65
+ // prepare?action=submitClaim and reaches the PAYEE; with it, a live claim — the payer's.
66
+ const b = body && typeof body === "object" ? body : null;
67
+ const liveClaim = !!b && typeof b.objectionDeadline === "string";
68
+ if (b && !liveClaim && V3_CLIENT_TEXT.payeeHints[code]) return V3_CLIENT_TEXT.payeeHints[code];
69
+ return V3_CLIENT_TEXT.codeHints[code] || null;
70
+ }
71
+
72
+ /** The fields of a V3 refusal body its recipient acts on, as one line (post-review G5) — the owner's rule. */
73
+ function v3RefusalDetail(body) {
74
+ if (!body || typeof body !== "object") return "";
75
+ const out = [];
76
+ if (typeof body.remedy === "string" && body.remedy) out.push(body.remedy);
77
+ if (Array.isArray(body.allowed)) out.push(`Allowed now: ${body.allowed.length ? body.allowed.map(String).join(", ") : "nothing"}.`);
78
+ if (typeof body.objectionDeadline === "string" && body.objectionDeadline) out.push(`Objection deadline: ${body.objectionDeadline}.`);
79
+ if (typeof body.offerExpiresAt === "string" && body.offerExpiresAt) out.push(`Open offer expires: ${body.offerExpiresAt}.`);
80
+ return out.join(" ");
81
+ }
82
+
83
+ /**
84
+ * The line for a `POST /api/contracts/join` answer — same rule as `joinedLine` in
85
+ * src/lib/v3-client-text.ts (v3-client-text-parity.test.ts runs this copy against it).
86
+ */
87
+ function joinedLine(body) {
88
+ const activated = body && typeof body === "object" ? body.activated : undefined;
89
+ if (activated === true) return V3_CLIENT_TEXT.joined.active;
90
+ if (activated === false) return V3_CLIENT_TEXT.joined.awaitingOffer;
91
+ return V3_CLIENT_TEXT.joined.unknown;
92
+ }
93
+
13
94
  // ─── HTTP helpers ─────────────────────────────────────────────────────────────
14
95
  // Authorization header is omitted entirely when key is empty, rather than
15
96
  // sending a garbage "Bearer " token — matters for `verify-work`, the one
@@ -19,20 +100,91 @@ function authHeader(key) {
19
100
  return key ? { "Authorization": `Bearer ${key}` } : {};
20
101
  }
21
102
 
22
- async function apiPost(path, body, key = API_KEY) {
23
- const res = await fetch(`${BASE_URL}${path}`, {
24
- method: "POST",
25
- headers: {
26
- ...authHeader(key),
27
- "Content-Type": "application/json",
28
- },
29
- body: JSON.stringify(body),
30
- });
31
- const data = await res.json();
32
- if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
103
+ // Client deadlines (T11-A4). This package cannot import src/, so the one with an
104
+ // owner is MIRRORED and checked against it by client-deadline-mirrors.test.ts:
105
+ // UPLOAD_CLIENT_ABORT_MS mirrors UPLOAD_CLIENT_ABORT_MS in src/lib/latency-budget.ts
106
+ // mcp/submit is 235 s like the stream (one step below its 240 s cap); everything
107
+ // else gets the longest maxDuration any route has. None of these calls had a
108
+ // deadline before.
109
+ const UPLOAD_CLIENT_ABORT_MS = 120_000;
110
+ const MCP_SUBMIT_CLIENT_ABORT_MS = 235_000;
111
+ const REQUEST_CLIENT_ABORT_MS = 300_000;
112
+
113
+ /** A response body parsed without trusting it to be JSON: an HTML 502 reads "HTTP 502 (non-JSON response from server)". */
114
+ async function readJsonSafe(res) {
115
+ const text = await res.text();
116
+ if (!text) return {};
117
+ try {
118
+ return JSON.parse(text);
119
+ } catch {
120
+ return { error: `HTTP ${res.status} (non-JSON response from server)`, code: "UPSTREAM_NON_JSON" };
121
+ }
122
+ }
123
+
124
+ /** Seconds the server asked us to wait: Retry-After, else `retry_after_seconds`. */
125
+ function retryAfterOf(res, data) {
126
+ const header = res.headers && typeof res.headers.get === "function" ? res.headers.get("retry-after") : null;
127
+ if (header !== null && /^\d+$/.test(header.trim())) return Number(header.trim());
128
+ return typeof data?.retry_after_seconds === "number" ? data.retry_after_seconds : null;
129
+ }
130
+
131
+ /**
132
+ * The one way this CLI calls the API. Throws an Error carrying `status`, `code` and
133
+ * `retryAfterSeconds`; its message ends with the code in brackets, so a script that
134
+ * greps stderr can branch on it. The deadline covers the body read.
135
+ */
136
+ async function fetchJson(method, path, { json, form, key = API_KEY, deadlineMs = REQUEST_CLIENT_ABORT_MS } = {}) {
137
+ const controller = new AbortController();
138
+ const timer = setTimeout(() => controller.abort(), deadlineMs);
139
+ let res;
140
+ let data;
141
+ try {
142
+ res = await fetch(`${BASE_URL}${path}`, {
143
+ method,
144
+ headers: { ...authHeader(key), ...(json !== undefined ? { "Content-Type": "application/json" } : {}) },
145
+ ...(json !== undefined ? { body: JSON.stringify(json) } : form !== undefined ? { body: form } : {}),
146
+ signal: controller.signal,
147
+ });
148
+ data = await readJsonSafe(res);
149
+ } catch (e) {
150
+ if (e && e.name === "AbortError") {
151
+ throw Object.assign(
152
+ new Error(`No answer from ${path} within ${Math.round(deadlineMs / 1000)} s. The request may still complete on the server — check its result before repeating it. [CLIENT_TIMEOUT]`),
153
+ { status: 0, code: "CLIENT_TIMEOUT", retryAfterSeconds: null }
154
+ );
155
+ }
156
+ throw e;
157
+ } finally {
158
+ clearTimeout(timer);
159
+ }
160
+ if (!res.ok || data.code === "UPSTREAM_NON_JSON") {
161
+ const code = typeof data.code === "string" ? data.code : null;
162
+ const retryAfterSeconds = retryAfterOf(res, data);
163
+ const base = typeof data.error === "string" && data.error ? data.error : `HTTP ${res.status}`;
164
+ // A platform error page on a POST (typically the 504 when a route hits its own
165
+ // maxDuration — contracts/review settles on chain inside its own) does not mean the
166
+ // call failed: the function may have moved money before it was killed. Say so, and
167
+ // how to check.
168
+ const mayHaveCompleted = method !== "GET" && res.status >= 500 && data.code === "UPSTREAM_NON_JSON";
169
+ throw Object.assign(
170
+ new Error(
171
+ `${base}${retryAfterSeconds !== null && (res.status === 429 || res.status === 503) ? ` — retry after ${retryAfterSeconds} s` : ""}${code ? ` [${code}]` : ""}` +
172
+ (mayHaveCompleted ? ". The request may still have completed on the server — check the contract (cascrow get --contract <id>) before repeating it." : "") +
173
+ // A MilestoneEscrowV3 refusal says who can act and how (F-72): the body's actionable
174
+ // fields (G5), then the owner's words, role-aware on the body (H9).
175
+ (v3RefusalDetail(data) ? ` — ${v3RefusalDetail(data)}` : "") +
176
+ (v3HintFor(code, data) ? `\n\n${v3HintFor(code, data)}` : "")
177
+ ),
178
+ { status: res.status, code, retryAfterSeconds }
179
+ );
180
+ }
33
181
  return data;
34
182
  }
35
183
 
184
+ async function apiPost(path, body, key = API_KEY, deadlineMs = REQUEST_CLIENT_ABORT_MS) {
185
+ return fetchJson("POST", path, { json: body, key, deadlineMs });
186
+ }
187
+
36
188
  // SEC-078. A failed standalone verification still has a row, and the route answers
37
189
  // the 500 with `id`, `publicHash` and `reportUrl` so the caller who paid for it can name it. The
38
190
  // CLI only prints a string, so the reference goes into the string. Both request
@@ -46,12 +198,7 @@ function standaloneFailure(status, data) {
46
198
  }
47
199
 
48
200
  async function apiGet(path, key = API_KEY) {
49
- const res = await fetch(`${BASE_URL}${path}`, {
50
- headers: { ...authHeader(key) },
51
- });
52
- const data = await res.json();
53
- if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
54
- return data;
201
+ return fetchJson("GET", path, { key });
55
202
  }
56
203
 
57
204
  async function uploadProof(milestoneId, filename, content, key = API_KEY) {
@@ -59,14 +206,7 @@ async function uploadProof(milestoneId, filename, content, key = API_KEY) {
59
206
  const form = new FormData();
60
207
  form.append("file", blob, filename);
61
208
  form.append("milestoneId", milestoneId);
62
- const res = await fetch(`${BASE_URL}/api/proof/upload`, {
63
- method: "POST",
64
- headers: { ...authHeader(key) },
65
- body: form,
66
- });
67
- const data = await res.json();
68
- if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
69
- return data;
209
+ return fetchJson("POST", "/api/proof/upload", { form, key, deadlineMs: UPLOAD_CLIENT_ABORT_MS });
70
210
  }
71
211
 
72
212
  // Stream verify via SSE — used by the legacy two-step `submit` + `verify --proof <id>` flow
@@ -112,8 +252,21 @@ async function streamVerify(proofId, key = API_KEY) {
112
252
  // long-lived MCP server that holds the event loop and later aborts a fetch
113
253
  // that finished minutes ago.
114
254
  clearTimeout(timeout);
115
- const data = await res.json().catch(() => ({}));
116
- throw new Error(data.error ?? `HTTP ${res.status}`);
255
+ const data = await readJsonSafe(res);
256
+ // T01-8. Upload starts verification by itself, so losing the claim to a RUNNING
257
+ // run is the normal case: wait for that run through proof-status instead of
258
+ // failing. This 409's Retry-After is the ~1 h takeover cutoff, never a wait.
259
+ if (res.status === 409 && data.code === "VERIFICATION_IN_PROGRESS" && data.phase === "running") {
260
+ process.stderr.write(" A verification of this proof is already running — waiting for it instead of starting another.\n");
261
+ const budgetMs = Math.max(30_000, Math.min(235_000, ((data.expected_done_in_seconds ?? 200) + 30) * 1000));
262
+ return { attached: true, status: await waitForProof(proofId, budgetMs, key) };
263
+ }
264
+ const code = typeof data.code === "string" ? data.code : null;
265
+ throw Object.assign(new Error(`${data.error ?? `HTTP ${res.status}`}${code ? ` [${code}]` : ""}`), {
266
+ status: res.status,
267
+ code,
268
+ retryAfterSeconds: retryAfterOf(res, data),
269
+ });
117
270
  }
118
271
  if (!res.body) {
119
272
  clearTimeout(timeout);
@@ -178,6 +331,90 @@ async function streamVerify(proofId, key = API_KEY) {
178
331
  throw new Error("Stream ended without a complete event");
179
332
  }
180
333
 
334
+ /**
335
+ * Poll proof-status until the proof settles, the run is found dead, or `maxMs` runs
336
+ * out. Returns the last status read, with `stoppedBecause` set when it stopped early:
337
+ * - 401 / 403: waiting does not change the key or the account;
338
+ * - 404: permanent — upload answers only after the proof exists and proof-status
339
+ * reads the primary database, so the proof was deleted or erased;
340
+ * - "interrupted": the run that held the proof died (claimPhase "orphaned").
341
+ * 429 / 503 wait at least Retry-After, never past the budget; anything else is
342
+ * transient. A milestone that is closed, or parked for a human, also settles it.
343
+ */
344
+ async function waitForProof(proofId, maxMs = 100_000, key = API_KEY, pollMs = 5_000) {
345
+ const started = Date.now();
346
+ let waitMs = pollMs;
347
+ let last = null;
348
+ while (Date.now() - started < maxMs) {
349
+ await new Promise((r) => setTimeout(r, waitMs));
350
+ waitMs = pollMs;
351
+ try {
352
+ last = await apiGet(`/api/agent/proof-status/${encodeURIComponent(proofId)}`, key);
353
+ } catch (e) {
354
+ if (e && (e.status === 401 || e.status === 403 || e.status === 404)) {
355
+ return { pending: true, proofId, stoppedBecause: e.status, error: e.message };
356
+ }
357
+ if (e && (e.status === 429 || e.status === 503) && typeof e.retryAfterSeconds === "number") {
358
+ waitMs = Math.min(Math.max(pollMs, e.retryAfterSeconds * 1000), Math.max(0, maxMs - (Date.now() - started)));
359
+ }
360
+ continue;
361
+ }
362
+ // `notCompletableWithoutEscrow`: no verdict can complete this milestone — final.
363
+ if (!last.pending || last.milestoneClosed === true || last.notCompletableWithoutEscrow === true) return { ...last, proofId };
364
+ // Parked for a human: no poll will change it (same rule as both MCP servers).
365
+ // Never a MilestoneEscrowV3 proof (post-review L2): no human review decides one.
366
+ if (last.escrowVersion !== 3 && last.running !== true && last.milestoneStatus === "PENDING_REVIEW") return { ...last, proofId, awaitingHumanReview: true };
367
+ if (last.claimPhase === "orphaned") return { ...last, proofId, stoppedBecause: "interrupted" };
368
+ }
369
+ return { ...(last ?? {}), pending: true, proofId, timedOut: true };
370
+ }
371
+
372
+ /** One line for a proof-status result, saying whether funds moved. */
373
+ function statusSummary(s) {
374
+ if (s.stoppedBecause === 404) return `⛔ Proof ${s.proofId} no longer exists (deleted or erased). Nothing will verify it.`;
375
+ 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.`;
376
+ if (s.stoppedBecause === "interrupted") {
377
+ // Not "re-run automatically": a claim with no timestamp is never taken over, and
378
+ // the retry cron skips demo contracts and milestones past PROOF_SUBMITTED.
379
+ return `⚠️ The last verification run was interrupted; nothing is running now. Once its claim is an hour old, \`cascrow verify --proof ${s.proofId}\` can start it again — or submit the proof again, which always works.`;
380
+ }
381
+ if (s.awaitingHumanReview) return "⏳ PENDING_REVIEW — the panel reached no verdict; a human decides. Waiting will not change it.";
382
+ if (s.timedOut) return `⏳ Still verifying. Check again: cascrow status --proof ${s.proofId} --wait`;
383
+ const conf = typeof s.confidence === "number" ? ` (${s.confidence}% confidence)` : "";
384
+ // MilestoneEscrowV3 (post-review G4): the verdict is a RECOMMENDATION and moves no money.
385
+ // Asked before every v2 arm — the claim-relevant answer is the SUBMISSION's verdict
386
+ // (`submissionVerdict`, `claimReady`), never this proof's `decision`, and a closed or
387
+ // COMPLETED V3 milestone was settled on chain by the parties.
388
+ if (s.escrowVersion === 3 && !(s.milestoneStatus === "COMPLETED" || s.milestoneClosed === true)) {
389
+ const line = V3_CLIENT_TEXT.recommendation.replace("{verdict}", s.submissionVerdict ?? "PENDING").replace("{confidence}", String(s.confidence ?? 0));
390
+ return s.manifestHash ? `${line} manifestHash: ${s.manifestHash}` : line;
391
+ }
392
+ if (s.escrowVersion === 3) {
393
+ return s.milestoneStatus === "COMPLETED"
394
+ ? "✅ COMPLETED — the MilestoneEscrowV3 escrow paid out on chain."
395
+ : `⛔ CLOSED — the milestone is ${s.terminalStatus ?? "closed"}; the MilestoneEscrowV3 escrow settled on chain. Do not resubmit to it.`;
396
+ }
397
+ // COMPLETED first: proof-status reports a paid milestone as `milestoneClosed` too
398
+ // (terminalStatus COMPLETED), and the CLOSED arm below would read a release as
399
+ // "moves no funds" — SEC-059's inversion (same order as proofOutcomeLine in both
400
+ // MCP servers). Whether it was THIS proof's YES is `decision`.
401
+ if (s.milestoneStatus === "COMPLETED" || s.terminalStatus === "COMPLETED") {
402
+ 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).`;
403
+ }
404
+ if (s.milestoneClosed === true) {
405
+ return s.terminalReason === "RELEASED_ON_CHAIN"
406
+ ? "⛔ CLOSED (EXPIRED) — the chain shows the builder WAS paid, just not by this verification."
407
+ : `⛔ CLOSED — the milestone is ${s.terminalStatus ?? "closed"}; this verification moved no funds. Do not resubmit to it.`;
408
+ }
409
+ // An approval cannot complete this milestone (an amount without a MilestoneEscrowV3
410
+ // escrow); the verdict doors answered 409 NOT_COMPLETABLE_WITHOUT_ESCROW.
411
+ if (s.notCompletableWithoutEscrow === true) return `⛔ NOT COMPLETABLE — ${V3_CLIENT_TEXT.codeHints.NOT_COMPLETABLE_WITHOUT_ESCROW}`;
412
+ if (s.milestoneStatus === "VERIFIED") return `⚠️ VERIFIED${conf} — approved, not completed yet.`;
413
+ if (s.milestoneStatus === "PENDING_REVIEW") return `⏳ PENDING_REVIEW${conf} — manual review required.`;
414
+ if (s.decision === "NO") return `❌ REJECTED${conf}.`;
415
+ return `ℹ️ Decision: ${s.decision ?? "none"}${conf}, milestone ${s.milestoneStatus ?? "unknown"}.`;
416
+ }
417
+
181
418
  /**
182
419
  * One human-readable line for a verification `complete` frame.
183
420
  *
@@ -188,22 +425,34 @@ async function streamVerify(proofId, key = API_KEY) {
188
425
  */
189
426
  function outcomeLine(r) {
190
427
  const c = r.confidence ?? 0;
428
+ // MilestoneEscrowV3: a recommendation that moves nothing — asked first, so no arm
429
+ // below can read it as a payout or a rejection.
430
+ if (r.action === "V3_RECOMMENDATION") {
431
+ // The SUBMISSION's verdict, never the panel's `decision` on one file.
432
+ return V3_CLIENT_TEXT.recommendation.replace("{verdict}", r.submissionVerdict ?? "PENDING").replace("{confidence}", String(c));
433
+ }
191
434
  if (r.action === "SUPERSEDED") {
192
- return "ℹ️ SUPERSEDED — another proof on this milestone was decided first, so this verification changed nothing. Your evidence was recorded.";
435
+ // `settledAs`: the milestone was closed (e.g. EXPIRED) before this verification could
436
+ // settle it, not beaten by a proof.
437
+ return r.settledAs
438
+ ? `ℹ️ SUPERSEDED — this milestone was closed (${r.settledAs}) before this verification could settle it, so it changed nothing and moved no funds. Do not resubmit to it.`
439
+ : "ℹ️ SUPERSEDED — another proof on this milestone was decided first, so this verification changed nothing. Your evidence was recorded.";
193
440
  }
194
441
  if (r.action === "COMPLETED") {
195
442
  return r.alreadyReleased === true
196
- ? "✅ COMPLETED — this milestone was already released by another request. The funds are out; do not resubmit."
197
- : `✅ COMPLETED (${c}% confidence) — funds released${r.txHash ? `, tx ${r.txHash}` : ""}.`;
443
+ ? `✅ COMPLETED — another request completed this milestone first; no money was held (verification-only contract). Do not resubmit.`
444
+ : `✅ COMPLETED (${c}% confidence) — no money was held (verification-only contract)${r.txHash ? `, tx ${r.txHash}` : ""}.`;
198
445
  }
199
446
  if (r.complianceBlocked === true) {
200
447
  return `⏸️ VERIFIED (${c}%) — payout on hold pending compliance review. No funds moved.`;
201
448
  }
202
- if (r.releaseHoldUntil) {
203
- 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.`;
449
+ if (r.action === "NOT_COMPLETABLE_WITHOUT_ESCROW") {
450
+ return `⛔ NOT COMPLETED (${c}% confidence) — ${V3_CLIENT_TEXT.codeHints.NOT_COMPLETABLE_WITHOUT_ESCROW}`;
204
451
  }
205
452
  if (r.action === "VERIFIED") {
206
- 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.`;
453
+ // Approved, but no completion followed; the producer does not reach this since
454
+ // Phase 6. No retry is promised — there is nothing to retry.
455
+ 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.`;
207
456
  }
208
457
  if (r.timedOut === true) {
209
458
  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.";
@@ -285,7 +534,7 @@ function err(msg) {
285
534
 
286
535
  function usage() {
287
536
  console.log(`
288
- Cascrow CLI — agentic escrow and verification
537
+ cascrow CLI — AI verification, on-chain reputation and non-custodial escrow for agents
289
538
 
290
539
  Usage:
291
540
  cascrow <command> [options]
@@ -295,15 +544,18 @@ Commands:
295
544
  verify-work Verify AI-generated work — no contract, no escrow, no account needed
296
545
  verify Submit proof and get AI verdict in one call (recommended)
297
546
  create Create a milestone contract
298
- fund Activate a milestone (simulation — no on-chain tx)
299
- escrow-fund Fund a milestone with real on-chain RLUSD escrow
300
- submit Upload proof text (returns proofId for legacy verify flow)
547
+ submit Upload proof text — verification starts by itself (then: status --proof <id> --wait)
548
+ status Show (or --wait for) the verification result of a proof
301
549
  get Get contract status and milestones
302
550
  join Join a contract as Builder via invite code
303
551
  handoff Send a contract invite to a Builder agent by Agent ID
304
552
  check-invites Check pending contract invites for this agent
305
553
  review-queue As funder: list milestones still waiting for your verdict
306
554
  review As funder: approve or reject a milestone under manual review
555
+ v3-claims As payer: list MilestoneEscrowV3 claims you can still object to on chain
556
+ v3 <sub> MilestoneEscrowV3, remote half: offer | acceptance | accept-submit | prepare | report-tx | attestation | claims
557
+ and "v3 sign <cascrow-signer command…>" — runs the pinned local signer (npx cascrow-signer@0.1.0),
558
+ which holds your key on YOUR machine. cascrow never signs, sends or relays for you.
307
559
  me Print this agent's ID
308
560
 
309
561
  Options for each command:
@@ -328,25 +580,25 @@ Options for each command:
328
580
  --commit <sha> optional Git commit SHA or PR URL
329
581
 
330
582
  create
331
- --title <title> required milestone title + acceptance criteria
583
+ --title <title> required short milestone title (printed on the certificate)
332
584
  --days <n> optional deadline days from now (default: 7)
333
- --amount <usd> optional RLUSD to lock in escrow (default: 0 = verification-only)
334
-
335
- fund
336
- --contract <id> required
337
- --milestone <id> optional defaults to first available
338
-
339
- escrow-fund [REAL on-chain RLUSD — agent's own wallet]
340
- --contract <id> required
341
- --private-key <key> required EVM private key (funds come from this wallet)
342
- --amount <usd> IGNORED the escrowed amount is the milestone's own amountUSD
343
- --milestone <id> optional
344
-
345
- submit [legacy: use verify instead]
585
+ --amount <usd> optional default 0 = verification-only (active once the builder joins). Above 0 the
586
+ milestone is a MilestoneEscrowV3 escrow in native XRP: offer it with
587
+ "cascrow v3 offer", the builder accepts, you fund with "cascrow v3 sign fund"
588
+ --criteria <text> optional what the AI panel checks the proof against (max 4000 chars)
589
+ --criteria-file <path> optional the same, read from a file
590
+ --checklist-file <path> optional JSON array of { title, severity?, id? } (max 20) scored item by item on text proofs
591
+ --review-mode <mode> optional AUTO | MANUAL | MANUAL_AUTO — what happens when the panel is unsure (60–85%)
592
+
593
+ submit [or use verify for text proof in one call]
346
594
  --milestone <id> required
347
595
  --proof <text> required
348
596
  --file <name> optional filename (default: proof-report.txt)
349
597
 
598
+ status
599
+ --proof <id> required
600
+ --wait optional wait up to ~2 min for the verdict; stops early when waiting cannot help
601
+
350
602
  get
351
603
  --contract <id> required
352
604
 
@@ -375,15 +627,13 @@ Examples:
375
627
  # Register a new agent account (no human in the loop)
376
628
  cascrow register --email bot@example.com --password secret123
377
629
 
378
- # One-shot: create contract, fund it, then verify a code fix
379
- cascrow create --title "Fix auth bug — all tests must pass" --days 7
380
- cascrow fund --contract <contractId>
630
+ # One-shot: create a verification-only contract (active once the builder joins), then verify a code fix
631
+ cascrow create --title "Fix auth bug" --criteria "All tests pass; JWT expiry honoured; no new lint errors" --days 7
381
632
  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
382
633
 
383
- # Real escrow flow (RLUSD locked on-chain)
384
- cascrow create --title "Deploy API" --amount 100 --days 14
385
- cascrow escrow-fund --contract <contractId> --private-key 0x...
386
- cascrow verify --contract <contractId> --proof "API live at https://api.example.com, all endpoints responding"
634
+ # Paid work is a MilestoneEscrowV3 escrow (native XRP, testnet): create with --amount,
635
+ # offer it (cascrow v3 offer), the builder accepts, and you fund with one transaction
636
+ # from your own machine (cascrow v3 sign fund). This CLI never takes a key.
387
637
 
388
638
  # Agent-to-agent handoff
389
639
  cascrow me # → get your Agent ID
@@ -395,14 +645,88 @@ Examples:
395
645
 
396
646
  # As the funder: decide what the AI panel could not (confidence 60-85)
397
647
  # Poll this — a manual_review.required webhook you missed is a decision nobody
398
- # makes, and cascrow auto-approves an undecided milestone after 48h.
648
+ # makes; an undecided milestone completes automatically after 48h under review mode
649
+ # MANUAL_AUTO or AUTO, 14 days otherwise.
650
+ # Verification-only contracts: a verdict completes or hands back, and moves no money.
399
651
  cascrow review-queue
400
652
  cascrow review --contract <contractId> --decision APPROVE
401
653
  cascrow review --contract <contractId> --decision REJECT --milestone <milestoneId>
654
+
655
+ # As the payer of a MilestoneEscrowV3 escrow: claims release unless YOUR wallet
656
+ # calls object(escrowId) before the deadline — cascrow cannot object for you.
657
+ cascrow v3-claims
658
+
659
+ # MilestoneEscrowV3 (native XRP). cascrow prepares and reads; you sign locally.
660
+ cascrow v3 offer --milestone <id> --review-period-sec 259200 [--watcher] [--arbiter 0x… --arbitration-period-sec N]
661
+ cascrow v3 acceptance --milestone <id> # as builder: the frozen terms to check
662
+ cascrow v3 sign accept --milestone <id> --network testnet # check + sign locally (pinned cascrow-signer)
663
+ cascrow v3 sign fund --milestone <id> --network testnet # as payer: one transaction, value = amount
664
+ cascrow v3 prepare --milestone <id> --action object --reason-hash 0x… # UNSIGNED — verify before signing
665
+ cascrow v3 report-tx --milestone <id> --tx 0x<64 hex> # a HASH only, never a signed transaction
666
+ cascrow v3 attestation --escrow 0x… --claim-index 1 # non-binding AI recommendation
402
667
  `);
403
668
  process.exit(0);
404
669
  }
405
670
 
671
+ // ─── The bar a milestone is judged against ────────────────────────────────────
672
+ // COPY of normalizeMilestoneChecklist / normalizeCriteria in src/lib/milestone-bar-input.ts
673
+ // (this package cannot import src/). milestone-bar-input-parity.test.ts runs this copy
674
+ // against the owner over the same case matrix.
675
+ const CHECKLIST_MAX_ITEMS = 20;
676
+ const CHECKLIST_TITLE_MAX = 200;
677
+ const CHECKLIST_ID_MAX = 100;
678
+ const CRITERIA_MAX = 4000;
679
+ const CHECKLIST_SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW"];
680
+
681
+ function normalizeMilestoneChecklist(items) {
682
+ if (items === undefined || items === null) return undefined;
683
+ if (!Array.isArray(items)) throw new Error("verificationChecklist: must be an array of { title, severity?, id? }");
684
+ if (items.length === 0) return undefined;
685
+ if (items.length > CHECKLIST_MAX_ITEMS) throw new Error(`verificationChecklist: at most ${CHECKLIST_MAX_ITEMS} items`);
686
+ items.forEach((raw, i) => {
687
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
688
+ throw new Error(`verificationChecklist[${i}]: must be an object with a title`);
689
+ }
690
+ });
691
+ const withId = items.filter((it) => it.id !== undefined).length;
692
+ if (withId !== 0 && withId !== items.length) {
693
+ throw new Error("verificationChecklist: either give every item an id or none (ids are then assigned 1..n)");
694
+ }
695
+ const seen = new Set();
696
+ return items.map((it, i) => {
697
+ const title = typeof it.title === "string" ? it.title.trim() : "";
698
+ if (title.length === 0) throw new Error(`verificationChecklist[${i}]: title is required`);
699
+ if (title.length > CHECKLIST_TITLE_MAX) throw new Error(`verificationChecklist[${i}]: title must be at most ${CHECKLIST_TITLE_MAX} characters`);
700
+ let id = i + 1;
701
+ if (withId) {
702
+ if (typeof it.id !== "number" || !Number.isInteger(it.id) || it.id < 1 || it.id > CHECKLIST_ID_MAX) {
703
+ throw new Error(`verificationChecklist[${i}]: id must be an integer from 1 to ${CHECKLIST_ID_MAX}`);
704
+ }
705
+ if (seen.has(it.id)) throw new Error(`verificationChecklist[${i}]: duplicate id ${it.id}`);
706
+ seen.add(it.id);
707
+ id = it.id;
708
+ }
709
+ const out = { id, title };
710
+ if (it.severity !== undefined && it.severity !== null && it.severity !== "") {
711
+ const sev = typeof it.severity === "string" ? it.severity.trim().toUpperCase() : "";
712
+ if (!CHECKLIST_SEVERITIES.includes(sev)) {
713
+ throw new Error(`verificationChecklist[${i}]: severity must be one of ${CHECKLIST_SEVERITIES.join(", ")}`);
714
+ }
715
+ out.severity = sev;
716
+ }
717
+ return out;
718
+ });
719
+ }
720
+
721
+ function normalizeCriteria(value) {
722
+ if (value === undefined || value === null) return undefined;
723
+ if (typeof value !== "string") throw new Error("verificationCriteria: must be a string");
724
+ const v = value.trim();
725
+ if (v.length === 0) return undefined;
726
+ if (v.length > CRITERIA_MAX) throw new Error(`verificationCriteria: at most ${CRITERIA_MAX} characters (got ${v.length})`);
727
+ return v;
728
+ }
729
+
406
730
  // ─── Argument parser ──────────────────────────────────────────────────────────
407
731
 
408
732
  function parseArgs(argv) {
@@ -439,8 +763,8 @@ async function cmdRegister(args) {
439
763
  keyName: args["key-name"] ?? "cli",
440
764
  }),
441
765
  });
442
- const data = await res.json();
443
- if (!res.ok) err(data.error ?? `HTTP ${res.status}`);
766
+ const data = await readJsonSafe(res);
767
+ if (!res.ok || data.code === "UPSTREAM_NON_JSON") err(`${data.error ?? `HTTP ${res.status}`}${data.code ? ` [${data.code}]` : ""}`);
444
768
  print(data);
445
769
  console.error("\nSave your API key and set it:\n export CASCROW_API_KEY=" + data.apiKey + "\n");
446
770
  }
@@ -484,7 +808,7 @@ async function cmdVerifyWork(args) {
484
808
  // The body is read INSIDE the deadline. A response whose headers arrived
485
809
  // and whose body then stalls is otherwise unbounded — a deadline that ends
486
810
  // at the headers bounds time-to-first-byte and nothing else (SEC-073).
487
- return { res: r, data: await r.json() };
811
+ return { res: r, data: await readJsonSafe(r) };
488
812
  });
489
813
  if (!res.ok) throw standaloneFailure(res.status, body);
490
814
  // Assigned to the OUTER `data`, not shadowing it: `const { data }` inside the
@@ -506,7 +830,7 @@ async function cmdVerifyWork(args) {
506
830
  // The body is read INSIDE the deadline. A response whose headers arrived
507
831
  // and whose body then stalls is otherwise unbounded — a deadline that ends
508
832
  // at the headers bounds time-to-first-byte and nothing else (SEC-073).
509
- return { res: r, data: await r.json() };
833
+ return { res: r, data: await readJsonSafe(r) };
510
834
  });
511
835
  if (!res.ok) throw standaloneFailure(res.status, answer);
512
836
  // Assigned to the OUTER `data`, not shadowing it: `const { data }` inside the
@@ -541,7 +865,8 @@ async function cmdVerifyWork(args) {
541
865
  }
542
866
 
543
867
  // One-shot verify: submit text proof + get verdict in a single call via /api/mcp/submit.
544
- // Auto-releases funds and mints NFT on approval — no separate `verify --proof <id>` needed.
868
+ // A verification-only milestone completes (NFT minted) on approval; a MilestoneEscrowV3
869
+ // verdict is a recommendation that moves no money. No separate `verify --proof <id>` needed.
545
870
  async function cmdVerify(args) {
546
871
  if (!API_KEY) err("CASCROW_API_KEY is not set");
547
872
  if (!args.proof) err("--proof is required");
@@ -554,6 +879,13 @@ async function cmdVerify(args) {
554
879
  if (!args.contract && args.proof && !args.proof.includes(" ")) {
555
880
  process.stderr.write("\nRunning AI verification (5 models)...\n\n");
556
881
  const result = await streamVerify(args.proof);
882
+ // The run was already going (usually the one upload started): print its outcome
883
+ // as proof-status reports it — the stream's frame never arrived here.
884
+ if (result && result.attached) {
885
+ print(result.status);
886
+ process.stderr.write(statusSummary(result.status) + "\n");
887
+ return;
888
+ }
557
889
  // SEC-055/058/059 at the third published surface. `passed` used to be
558
890
  // `decision === "YES" && confidence >= 85`, i.e. a re-implementation of the
559
891
  // three-tier rule from the PROOF's verdict — which answers a different question
@@ -573,10 +905,18 @@ async function cmdVerify(args) {
573
905
  released,
574
906
  alreadyReleased: result.alreadyReleased === true,
575
907
  superseded: result.action === "SUPERSEDED",
908
+ // With SUPERSEDED: the TERMINAL status the row was already in (EXPIRED, COMPLETED),
909
+ // or null when another proof won it. It used to reach only the stderr line from
910
+ // `outcomeLine`, so a machine caller reading this JSON could not tell "closed —
911
+ // nothing moved, do not resubmit" from "another proof won" (round-3 pass 2).
912
+ settledAs: typeof result.settledAs === "string" ? result.settledAs : null,
576
913
  complianceBlocked: result.complianceBlocked === true,
577
- releaseHoldUntil: result.releaseHoldUntil ?? null,
914
+ notCompletableWithoutEscrow: result.action === "NOT_COMPLETABLE_WITHOUT_ESCROW",
578
915
  panelUnavailable: result.timedOut === true,
579
916
  txHash: result.txHash ?? null,
917
+ // MilestoneEscrowV3 (post-review G4): the recommendation's own fields, so a machine
918
+ // caller reads the SUBMISSION's verdict and the hash to claim with.
919
+ ...(result.action === "V3_RECOMMENDATION" || result.escrowVersion === 3 ? v3Fields(result) : {}),
580
920
  reasoning: result.reasoning,
581
921
  modelVotes: (result.votes ?? []).map(v => ({ model: v.model, decision: v.decision, confidence: v.confidence })),
582
922
  });
@@ -601,20 +941,23 @@ async function cmdVerify(args) {
601
941
  contract_id: args.contract,
602
942
  milestone_id: args.milestone ?? undefined,
603
943
  evidence,
604
- });
944
+ }, API_KEY, MCP_SUBMIT_CLIENT_ABORT_MS);
605
945
 
606
946
  // `decided: "elsewhere"` and `panelUnavailable` are 200 responses that carry NO
607
947
  // `verdict` key, so `data.verdict ?? "unknown"` fell to the final arm below and told
608
948
  // the builder "❌ REJECTED — resubmit with stronger proof" for a milestone that may
609
949
  // well have been approved and paid (SEC-055). Classified before the verdict is read.
610
- const verdict = data.decided === "elsewhere"
950
+ const isV3 = data.action === "V3_RECOMMENDATION";
951
+ const verdict = isV3
952
+ ? "v3_recommendation"
953
+ : data.decided === "elsewhere"
611
954
  ? "decided_elsewhere"
612
955
  : data.panelUnavailable
613
956
  ? "panel_unavailable"
614
957
  : (data.verdict ?? "unknown");
615
958
  const confidence = data.confidence ?? 0;
616
- // Approved, payout SCHEDULED, funds still in escrow so the funder can contest.
617
- const releaseHoldUntil = data.release_hold_until ?? null;
959
+ // An approved milestone that cannot complete is not a 200: the route answers 409
960
+ // NOT_COMPLETABLE_WITHOUT_ESCROW, which `apiPost` turns into an error with its hint.
618
961
 
619
962
  // Print model votes if available
620
963
  const votes = Array.isArray(data.model_votes) ? data.model_votes : [];
@@ -624,8 +967,22 @@ async function cmdVerify(args) {
624
967
  }
625
968
  process.stderr.write("\n");
626
969
 
970
+ // With `decided: "elsewhere"`: the TERMINAL status the milestone was already in. EXPIRED
971
+ // is a funder cancel or the deadline, not "the funder's review, or a concurrent
972
+ // submission", and resubmitting to it is pointless.
973
+ const settledAs = verdict === "decided_elsewhere" && typeof data.settledAs === "string" ? data.settledAs : null;
974
+
627
975
  let statusLine;
628
- if (verdict === "decided_elsewhere") {
976
+ if (isV3) {
977
+ // A MilestoneEscrowV3 submission: sealed, recommended on, nothing moved (G4).
978
+ statusLine = V3_CLIENT_TEXT.recommendation.replace("{verdict}", data.submissionVerdict ?? "PENDING").replace("{confidence}", String(confidence));
979
+ if (data.manifestHash) statusLine += `\n manifestHash: ${data.manifestHash}`;
980
+ if (typeof data.next === "string") statusLine += `\n ${data.next}`;
981
+ } else if (settledAs) {
982
+ statusLine =
983
+ `ℹ️ This milestone was closed (${settledAs}) before this submission could settle it, so this submission ` +
984
+ "changed nothing and moved no funds itself. Your evidence was recorded. Do not resubmit to this milestone";
985
+ } else if (verdict === "decided_elsewhere") {
629
986
  statusLine =
630
987
  "ℹ️ This milestone was decided by another request while your proof was being verified — " +
631
988
  "by the funder's review, or by a concurrent submission. Your evidence was recorded. " +
@@ -635,10 +992,6 @@ async function cmdVerify(args) {
635
992
  "⏳ PENDING_REVIEW — the verification panel could not reach a verdict (too few models " +
636
993
  "answered). This is NOT a rejection and NOT a judgement of your work; a human decides, " +
637
994
  "and resubmitting the same evidence will not help while the panel is down";
638
- } else if (verdict === "approved" && releaseHoldUntil) {
639
- statusLine =
640
- `⏸️ APPROVED (${confidence}% confidence) — payout scheduled for ${releaseHoldUntil}. ` +
641
- "NO FUNDS HAVE MOVED YET: the funder can contest until then, and the date can move";
642
995
  } else if (verdict === "approved") {
643
996
  statusLine = `✅ VERIFIED (${confidence}% confidence)`;
644
997
  if (data.on_chain_url) statusLine += `\n Proof: ${data.on_chain_url}`;
@@ -659,17 +1012,28 @@ async function cmdVerify(args) {
659
1012
  print({
660
1013
  verdict,
661
1014
  confidence,
662
- // MONEY MOVED — not "the verdict was approved". A scheduled payout has not happened.
663
- passed: verdict === "approved" && !releaseHoldUntil,
664
- release_hold_until: releaseHoldUntil,
1015
+ passed: verdict === "approved",
1016
+ ...(settledAs ? { settled_as: settledAs } : {}),
665
1017
  reasoning: data.reasoning ?? "",
666
1018
  on_chain_url: data.on_chain_url ?? null,
667
1019
  proof_id: data.proof_id ?? null,
668
1020
  signed_at: data.signed_at ?? null,
1021
+ ...(isV3 ? v3Fields(data) : {}),
669
1022
  model_votes: votes,
670
1023
  });
671
1024
  }
672
1025
 
1026
+ /** The MilestoneEscrowV3 fields of an answer, for JSON output (post-review G4). Never derived: forwarded. */
1027
+ function v3Fields(r) {
1028
+ return {
1029
+ escrowVersion: 3,
1030
+ submissionId: r.submissionId ?? null,
1031
+ manifestHash: r.manifestHash ?? null,
1032
+ submissionVerdict: r.submissionVerdict ?? "PENDING",
1033
+ claimReady: r.claimReady === true,
1034
+ };
1035
+ }
1036
+
673
1037
  async function cmdCreate(args) {
674
1038
  if (!API_KEY) err("CASCROW_API_KEY is not set");
675
1039
  if (!args.title) err("--title is required");
@@ -679,12 +1043,40 @@ async function cmdCreate(args) {
679
1043
  deadline.setDate(deadline.getDate() + deadlineDays);
680
1044
  const amountUSD = parseFloat(args.amount ?? "0");
681
1045
 
1046
+ // T07. The bar the panel judges against — kept OUT of the title, which is printed
1047
+ // on the public certificate. Normalised by a copy of src/lib/milestone-bar-input.ts
1048
+ // (milestone-bar-input-parity.test.ts runs it against the owner).
1049
+ let criteriaRaw = typeof args.criteria === "string" ? args.criteria : undefined;
1050
+ if (typeof args["criteria-file"] === "string") criteriaRaw = readFileSync(args["criteria-file"], "utf8");
1051
+ let checklistRaw;
1052
+ if (typeof args["checklist-file"] === "string") {
1053
+ try {
1054
+ checklistRaw = JSON.parse(readFileSync(args["checklist-file"], "utf8"));
1055
+ } catch (e) {
1056
+ err(`--checklist-file: not a JSON array of { title, severity?, id? } (${e.message})`);
1057
+ }
1058
+ }
1059
+ const reviewMode = typeof args["review-mode"] === "string" ? args["review-mode"].toUpperCase() : undefined;
1060
+ if (reviewMode !== undefined && !["AUTO", "MANUAL", "MANUAL_AUTO"].includes(reviewMode)) {
1061
+ err("--review-mode must be AUTO, MANUAL or MANUAL_AUTO");
1062
+ }
1063
+ let criteria, checklist;
1064
+ try {
1065
+ criteria = normalizeCriteria(criteriaRaw);
1066
+ checklist = normalizeMilestoneChecklist(checklistRaw);
1067
+ } catch (e) {
1068
+ err(e.message);
1069
+ }
1070
+
682
1071
  const data = await apiPost("/api/contracts", {
683
1072
  milestones: [{
684
1073
  title: args.title,
685
1074
  amountUSD,
686
1075
  cancelAfter: deadline.toISOString(),
1076
+ ...(criteria !== undefined ? { verificationCriteria: criteria } : {}),
1077
+ ...(checklist !== undefined ? { verificationChecklist: checklist } : {}),
687
1078
  }],
1079
+ ...(reviewMode !== undefined ? { agentReviewMode: reviewMode } : {}),
688
1080
  });
689
1081
 
690
1082
  const contractUrl = `${BASE_URL}/contract/${data.contractId}`;
@@ -692,42 +1084,28 @@ async function cmdCreate(args) {
692
1084
  process.stderr.write(`\nContract created: ${contractUrl}\n\n`);
693
1085
  }
694
1086
 
695
- async function cmdFund(args) {
696
- if (!API_KEY) err("CASCROW_API_KEY is not set");
697
- if (!args.contract) err("--contract is required");
698
-
699
- const body = args.milestone
700
- ? { contractId: args.contract, milestoneId: args.milestone }
701
- : { contractId: args.contract };
702
-
703
- const data = await apiPost("/api/agent/fund-milestone", body);
704
- print(data);
705
- process.stderr.write(`\nMilestone funded (simulation). ID: ${data.milestoneId}\n\n`);
1087
+ // Removed in 3.0.0 (cascrow Phase 6, owner decision 16): `fund` activated a v2
1088
+ // milestone. A $0 verification contract is active by itself once the builder has
1089
+ // joined, and a milestone with an amount is a MilestoneEscrowV3 escrow its payer
1090
+ // funds from their own machine. Kept as a command only to say so; it sends nothing.
1091
+ function cmdFundRemoved() {
1092
+ err(`${V3_CLIENT_TEXT.fundRemoved} [TOOL_REMOVED]`);
706
1093
  }
707
1094
 
708
- async function cmdEscrowFund(args) {
709
- if (!API_KEY) err("CASCROW_API_KEY is not set");
710
- if (!args.contract) err("--contract is required");
711
- if (!args["private-key"]) err("--private-key is required (EVM private key, funds come from this wallet)");
712
-
713
- const body = {
714
- contractId: args.contract,
715
- agentPrivateKey: args["private-key"],
716
- };
717
- if (args.milestone) body.milestoneId = args.milestone;
718
- if (args.amount) {
719
- // NOT forwarded. `agent/escrow-fund` funds `milestone.amountUSD` and ignores
720
- // a body field, so `--amount` never overrode anything — it only told the
721
- // user it had. Say so rather than sending a number the server drops.
722
- process.stderr.write(
723
- "\n⚠️ --amount is ignored for escrow funding: the escrowed amount is the milestone's own amountUSD.\n"
724
- );
725
- }
726
-
727
- process.stderr.write("\nFunding milestone on-chain (approve + fundMilestone)...\n");
728
- const data = await apiPost("/api/agent/escrow-fund", body);
729
- print(data);
730
- process.stderr.write(`\n✅ ${data.amountUSD} RLUSD locked on-chain. TX: ${data.txHash}\n\n`);
1095
+ // Removed in 2.0.0 (cascrow Phase 5): `escrow-fund --private-key` sent the key
1096
+ // to cascrow, which signed the deposit server-side. Kept as a command only to say
1097
+ // so: it sends NOTHING — no request, no argument — so a key typed on the command
1098
+ // line goes nowhere. It cannot un-type it, which is why the message tells the user
1099
+ // what a key that reached a shell history (or an older version of this CLI) is now.
1100
+ function cmdEscrowFundRemoved() {
1101
+ err(
1102
+ "escrow-fund was removed in cascrow-cli 2.0.0: cascrow never accepts, creates, stores or signs with private keys. " +
1103
+ "A $0 verification contract needs no funding — it is active once the builder has joined. A milestone with an amount " +
1104
+ "is a MilestoneEscrowV3 escrow in native XRP, funded by the payer with one transaction from their own machine: first link " +
1105
+ "the wallet you will fund from (GET /api/user/wallet/challenge, sign the message, POST /api/user/wallet), then run " +
1106
+ "npx cascrow-signer@0.1.0 fund --milestone <milestoneId>. If you ever passed a private key to this command (it is also in your shell history), " +
1107
+ "treat that key as exposed and move its funds to a new wallet. [CUSTODY_PATH_REMOVED]"
1108
+ );
731
1109
  }
732
1110
 
733
1111
  // Legacy two-step: upload proof → returns proofId → user runs `cascrow verify --proof <id>`
@@ -742,7 +1120,25 @@ async function cmdSubmit(args) {
742
1120
  args.proof
743
1121
  );
744
1122
  print(data);
745
- process.stderr.write(`\nProof submitted. Run: cascrow verify --proof ${data.proofId}\n\n`);
1123
+ // Upload starts verification by itself — `verify --proof` would only race it.
1124
+ process.stderr.write(`\nProof submitted; verification has started. Wait for the verdict: cascrow status --proof ${data.proofId} --wait\n\n`);
1125
+ }
1126
+
1127
+ async function cmdStatus(args) {
1128
+ if (!API_KEY) err("CASCROW_API_KEY is not set");
1129
+ if (!args.proof) err("--proof is required");
1130
+ if (args.wait) {
1131
+ process.stderr.write("\nWaiting for the verification to settle (up to ~2 min)...\n\n");
1132
+ const s = await waitForProof(args.proof, 120_000);
1133
+ print(s);
1134
+ process.stderr.write("\n" + statusSummary(s) + "\n\n");
1135
+ return;
1136
+ }
1137
+ const data = await apiGet(`/api/agent/proof-status/${encodeURIComponent(args.proof)}`);
1138
+ print(data);
1139
+ process.stderr.write(
1140
+ "\n" + (data.pending && data.milestoneClosed !== true ? `⏳ Still verifying${data.claimPhase === "orphaned" ? " — the run was interrupted" : ""}.` : statusSummary({ ...data, proofId: args.proof })) + "\n\n"
1141
+ );
746
1142
  }
747
1143
 
748
1144
  async function cmdGet(args) {
@@ -757,7 +1153,8 @@ async function cmdJoin(args) {
757
1153
  if (!args.invite) err("--invite is required");
758
1154
  const data = await apiPost("/api/contracts/join", { inviteCode: args.invite });
759
1155
  print(data);
760
- process.stderr.write(`\nJoined contract: ${data.contractId}\n\n`);
1156
+ // `activated`: a $0 contract is active now; a money contract waits for its V3 offer.
1157
+ process.stderr.write(`\nJoined contract: ${data.contractId}\n${joinedLine(data)}\n\n`);
761
1158
  }
762
1159
 
763
1160
  async function cmdHandoff(args) {
@@ -797,9 +1194,9 @@ async function cmdCheckInvites() {
797
1194
  * verdict on cannot come apart (F-22).
798
1195
  *
799
1196
  * `review-queue` is the pull behind the `manual_review.required` webhook: a
800
- * webhook nobody received is a decision nobody makes, and cascrow auto-approves
801
- * an undecided milestone after 48 h (agent contracts) or 14 days (human) and
802
- * releases the escrow. `review` ships with it rather than later, because a list
1197
+ * webhook nobody received is a decision nobody makes, and cascrow completes an
1198
+ * undecided milestone after 48 h (agent contracts in MANUAL_AUTO/AUTO) or 14 days
1199
+ * (every other contract). Verification-only contracts: no money moves. `review` ships with it rather than later, because a list
803
1200
  * its reader cannot act on names a remedy they are unable to perform (F-72).
804
1201
  */
805
1202
  async function cmdReviewQueue() {
@@ -807,8 +1204,16 @@ async function cmdReviewQueue() {
807
1204
  const data = await apiGet("/api/agent/review-queue");
808
1205
  const contracts = data.contracts ?? [];
809
1206
  print(data);
1207
+ // Said in both branches: an empty v2 queue is exactly when a reader stops
1208
+ // looking, and a V3 claim window keeps running.
1209
+ const v3 =
1210
+ data.v3ClaimsPending === null
1211
+ ? "Open MilestoneEscrowV3 claims could not be counted — run: cascrow v3-claims\n\n"
1212
+ : data.v3ClaimsPending > 0
1213
+ ? `${data.v3ClaimsPending} MilestoneEscrowV3 claim(s) can still be objected to — run: cascrow v3-claims\n\n`
1214
+ : "";
810
1215
  if (contracts.length === 0) {
811
- process.stderr.write("\nNo milestones are waiting for your verdict.\n\n");
1216
+ process.stderr.write(`\nNo milestones are waiting for your verdict.\n\n${v3}`);
812
1217
  return;
813
1218
  }
814
1219
  const lines = contracts.map((c) => {
@@ -819,15 +1224,100 @@ async function cmdReviewQueue() {
819
1224
  : pending.length > 1
820
1225
  ? `${pending.length} milestones pending — pass --milestone <id>`
821
1226
  : "contract-level review — omit --milestone";
822
- return ` ${c.contractId} ${c.amountUSD} RLUSD (${ms})`;
1227
+ return ` ${c.contractId} (${ms})`;
823
1228
  }).join("\n");
824
1229
  const more = data.truncated ? `\n(${data.total} in total — this list is capped.)` : "";
825
1230
  process.stderr.write(
826
1231
  `\n${data.total} contract(s) waiting for your verdict:\n${lines}${more}\n\n` +
827
- "Decide with: cascrow review --contract <id> --decision APPROVE|REJECT [--milestone <id>]\n\n"
1232
+ "Decide with: cascrow review --contract <id> --decision APPROVE|REJECT [--milestone <id>]\n\n" +
1233
+ v3
828
1234
  );
829
1235
  }
830
1236
 
1237
+ /**
1238
+ * The payer's open MilestoneEscrowV3 claims — `GET /api/agent/v3-claims`, the
1239
+ * pull behind the `claim.submitted` webhook. Print only: the action is the
1240
+ * payer's own `object(escrowId)` on chain, which cascrow never performs.
1241
+ */
1242
+ async function cmdV3Claims() {
1243
+ if (!API_KEY) err("CASCROW_API_KEY is not set");
1244
+ const data = await apiGet("/api/agent/v3-claims");
1245
+ const claims = data.claims ?? [];
1246
+ print(data);
1247
+ if (claims.length === 0) {
1248
+ process.stderr.write("\nNo MilestoneEscrowV3 claim is waiting on you.\n\n");
1249
+ return;
1250
+ }
1251
+ const lines = claims.map((c) =>
1252
+ ` ${c.escrowId} on ${c.chainId}/${c.escrowContract} claim ${c.claimIndex}` +
1253
+ `${c.secondFunding ? " SECOND funding" : ""}${c.held ? " (held — verify on chain)" : ""} ` +
1254
+ (c.objectable === null ? "claim state unknown here — read getEscrow on chain" : c.objectable ? `object() before ${c.objectionDeadline}` : `window closed at ${c.objectionDeadline}`)
1255
+ ).join("\n");
1256
+ const more = data.truncated ? "\n(This list is capped; more claims exist.)" : "";
1257
+ 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`);
1258
+ }
1259
+
1260
+ // ─── MilestoneEscrowV3, remote half (design Rev. 10) ──────────────────────────
1261
+ // cascrow prepares and reads; the party signs and sends with its own key. This CLI
1262
+ // holds no key and has no dependency: `v3 sign …` runs the PINNED local signer
1263
+ // (V3_CLIENT_TEXT.signerPin) in a child process, with this terminal attached, so
1264
+ // its prompts and its checks are the signer's own.
1265
+ async function cmdV3(sub, args, rawArgs) {
1266
+ if (sub === "sign") {
1267
+ const child = spawn("npx", ["--yes", V3_CLIENT_TEXT.signerPin, ...rawArgs], { stdio: "inherit", shell: process.platform === "win32" });
1268
+ const code = await new Promise((resolve) => child.on("close", resolve));
1269
+ process.exit(typeof code === "number" ? code : 1);
1270
+ }
1271
+ if (!API_KEY) err("CASCROW_API_KEY is not set");
1272
+ switch (sub) {
1273
+ case "offer": {
1274
+ if (!args.milestone || !args["review-period-sec"]) err("--milestone and --review-period-sec are required");
1275
+ const body = {
1276
+ milestoneId: args.milestone,
1277
+ reviewPeriodSec: Number(args["review-period-sec"]),
1278
+ watcherDeclared: args.watcher === true || args.watcher === "true",
1279
+ ...(args.arbiter ? { arbiter: args.arbiter } : {}),
1280
+ ...(args["arbitration-period-sec"] ? { arbitrationPeriodSec: Number(args["arbitration-period-sec"]) } : {}),
1281
+ };
1282
+ print(await apiPost("/api/escrow/v3/offer", body));
1283
+ process.stderr.write(`\nOffer sent. After the builder accepts, fund it from your machine: cascrow v3 sign fund --milestone ${args.milestone}\n\n`);
1284
+ return;
1285
+ }
1286
+ case "acceptance":
1287
+ if (!args.milestone) err("--milestone is required");
1288
+ print(await apiGet(`/api/escrow/v3/acceptance?milestoneId=${encodeURIComponent(args.milestone)}`));
1289
+ 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");
1290
+ return;
1291
+ case "accept-submit":
1292
+ if (!args.offer || !args.signature) err("--offer and --signature are required");
1293
+ print(await apiPost("/api/escrow/v3/acceptance", { offerId: args.offer, signature: args.signature }));
1294
+ return;
1295
+ case "prepare": {
1296
+ if (!args.milestone || !args.action) err("--milestone and --action are required");
1297
+ const q = new URLSearchParams({ milestoneId: args.milestone, action: args.action });
1298
+ if (args["proof-hash"]) q.set("proofHash", args["proof-hash"]);
1299
+ if (args["reason-hash"]) q.set("reasonHash", args["reason-hash"]);
1300
+ if (args["new-deadline"]) q.set("newDeadline", String(args["new-deadline"]));
1301
+ print(await apiGet(`/api/escrow/v3/prepare?${q.toString()}`));
1302
+ 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");
1303
+ return;
1304
+ }
1305
+ case "report-tx":
1306
+ if (!args.milestone || typeof args.tx !== "string" || !TX_HASH_RE.test(args.tx)) err(V3_CLIENT_TEXT.reportTxOnlyHash);
1307
+ print(await apiPost("/api/escrow/v3/sync", { milestoneId: args.milestone, txHash: args.tx }));
1308
+ return;
1309
+ case "attestation":
1310
+ if (!args.escrow || !args["claim-index"]) err("--escrow and --claim-index are required");
1311
+ print(await apiGet(`/api/escrow/v3/attestation?${new URLSearchParams({ escrowId: args.escrow, claimIndex: String(args["claim-index"]) }).toString()}`));
1312
+ return;
1313
+ case "claims":
1314
+ await cmdV3Claims();
1315
+ return;
1316
+ default:
1317
+ err(`unknown v3 command ${sub ?? "(none)"} — offer | acceptance | accept-submit | prepare | report-tx | attestation | claims | sign`);
1318
+ }
1319
+ }
1320
+
831
1321
  async function cmdReview(args) {
832
1322
  if (!API_KEY) err("CASCROW_API_KEY is not set");
833
1323
  const contractId = args.contract;
@@ -837,11 +1327,14 @@ async function cmdReview(args) {
837
1327
  const body = args.milestone ? { contractId, decision, milestoneId: args.milestone } : { contractId, decision };
838
1328
  const data = await apiPost("/api/contracts/review", body);
839
1329
  print(data);
1330
+ // The route answers `{ status }` and nothing else: a review only decides
1331
+ // verification-only ($0) milestones, and nothing moves money (a MilestoneEscrowV3
1332
+ // milestone is refused — its payer releases or objects on chain).
840
1333
  const what =
841
1334
  decision === "APPROVE"
842
- ? "approved — the escrow is being released to the builder"
1335
+ ? "approved and completed (a verification-only contract: no money moves)"
843
1336
  : "rejected — the milestone is back with the builder, who can resubmit";
844
- process.stderr.write(`\nMilestone ${what}.${data.txHash ? `\nRelease tx: ${data.txHash}` : ""}\n\n`);
1337
+ process.stderr.write(`\nMilestone ${what}.${data.status ? ` Contract status: ${data.status}.` : ""}\n\n`);
845
1338
  }
846
1339
 
847
1340
  async function cmdMe() {
@@ -866,15 +1359,18 @@ try {
866
1359
  case "verify-work": await cmdVerifyWork(args); break;
867
1360
  case "verify": await cmdVerify(args); break;
868
1361
  case "create": await cmdCreate(args); break;
869
- case "fund": await cmdFund(args); break;
870
- case "escrow-fund": await cmdEscrowFund(args); break;
1362
+ case "fund": cmdFundRemoved(); break;
1363
+ case "escrow-fund": cmdEscrowFundRemoved(); break;
871
1364
  case "submit": await cmdSubmit(args); break;
1365
+ case "status": await cmdStatus(args); break;
872
1366
  case "get": await cmdGet(args); break;
873
1367
  case "join": await cmdJoin(args); break;
874
1368
  case "handoff": await cmdHandoff(args); break;
875
1369
  case "check-invites": await cmdCheckInvites(); break;
876
1370
  case "review-queue": await cmdReviewQueue(); break;
877
1371
  case "review": await cmdReview(args); break;
1372
+ case "v3-claims": await cmdV3Claims(); break;
1373
+ case "v3": await cmdV3(rest[0], parseArgs(rest.slice(1)), rest.slice(1)); break;
878
1374
  case "me": await cmdMe(); break;
879
1375
  default:
880
1376
  console.error(`Unknown command: ${command}\n`);