cascrow-cli 1.2.2 → 1.4.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
@@ -19,27 +19,100 @@ function authHeader(key) {
19
19
  return key ? { "Authorization": `Bearer ${key}` } : {};
20
20
  }
21
21
 
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}`);
22
+ // Client deadlines (T11-A4). This package cannot import src/, so the one with an
23
+ // owner is MIRRORED and checked against it by client-deadline-mirrors.test.ts:
24
+ // UPLOAD_CLIENT_ABORT_MS mirrors UPLOAD_CLIENT_ABORT_MS in src/lib/latency-budget.ts
25
+ // mcp/submit is 235 s like the stream (one step below its 240 s cap); everything
26
+ // else gets the longest maxDuration any route has. None of these calls had a
27
+ // deadline before.
28
+ const UPLOAD_CLIENT_ABORT_MS = 120_000;
29
+ const MCP_SUBMIT_CLIENT_ABORT_MS = 235_000;
30
+ const REQUEST_CLIENT_ABORT_MS = 300_000;
31
+
32
+ /** A response body parsed without trusting it to be JSON: an HTML 502 reads "HTTP 502 (non-JSON response from server)". */
33
+ async function readJsonSafe(res) {
34
+ const text = await res.text();
35
+ if (!text) return {};
36
+ try {
37
+ return JSON.parse(text);
38
+ } catch {
39
+ return { error: `HTTP ${res.status} (non-JSON response from server)`, code: "UPSTREAM_NON_JSON" };
40
+ }
41
+ }
42
+
43
+ /** Seconds the server asked us to wait: Retry-After, else `retry_after_seconds`. */
44
+ function retryAfterOf(res, data) {
45
+ const header = res.headers && typeof res.headers.get === "function" ? res.headers.get("retry-after") : null;
46
+ if (header !== null && /^\d+$/.test(header.trim())) return Number(header.trim());
47
+ return typeof data?.retry_after_seconds === "number" ? data.retry_after_seconds : null;
48
+ }
49
+
50
+ /**
51
+ * The one way this CLI calls the API. Throws an Error carrying `status`, `code` and
52
+ * `retryAfterSeconds`; its message ends with the code in brackets, so a script that
53
+ * greps stderr can branch on it. The deadline covers the body read.
54
+ */
55
+ async function fetchJson(method, path, { json, form, key = API_KEY, deadlineMs = REQUEST_CLIENT_ABORT_MS } = {}) {
56
+ const controller = new AbortController();
57
+ const timer = setTimeout(() => controller.abort(), deadlineMs);
58
+ let res;
59
+ let data;
60
+ try {
61
+ res = await fetch(`${BASE_URL}${path}`, {
62
+ method,
63
+ headers: { ...authHeader(key), ...(json !== undefined ? { "Content-Type": "application/json" } : {}) },
64
+ ...(json !== undefined ? { body: JSON.stringify(json) } : form !== undefined ? { body: form } : {}),
65
+ signal: controller.signal,
66
+ });
67
+ data = await readJsonSafe(res);
68
+ } catch (e) {
69
+ if (e && e.name === "AbortError") {
70
+ throw Object.assign(
71
+ 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]`),
72
+ { status: 0, code: "CLIENT_TIMEOUT", retryAfterSeconds: null }
73
+ );
74
+ }
75
+ throw e;
76
+ } finally {
77
+ clearTimeout(timer);
78
+ }
79
+ if (!res.ok || data.code === "UPSTREAM_NON_JSON") {
80
+ const code = typeof data.code === "string" ? data.code : null;
81
+ const retryAfterSeconds = retryAfterOf(res, data);
82
+ const base = typeof data.error === "string" && data.error ? data.error : `HTTP ${res.status}`;
83
+ // 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.
86
+ const mayHaveCompleted = method !== "GET" && res.status >= 500 && data.code === "UPSTREAM_NON_JSON";
87
+ throw Object.assign(
88
+ new Error(
89
+ `${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." : "")
91
+ ),
92
+ { status: res.status, code, retryAfterSeconds }
93
+ );
94
+ }
33
95
  return data;
34
96
  }
35
97
 
98
+ async function apiPost(path, body, key = API_KEY, deadlineMs = REQUEST_CLIENT_ABORT_MS) {
99
+ return fetchJson("POST", path, { json: body, key, deadlineMs });
100
+ }
101
+
102
+ // SEC-078. A failed standalone verification still has a row, and the route answers
103
+ // the 500 with `id`, `publicHash` and `reportUrl` so the caller who paid for it can name it. The
104
+ // CLI only prints a string, so the reference goes into the string. Both request
105
+ // shapes (multipart image, JSON) throw through here.
106
+ function standaloneFailure(status, data) {
107
+ const d = data ?? {};
108
+ const base = typeof d.error === "string" ? d.error : `HTTP ${status}`;
109
+ const report = typeof d.reportUrl === "string" ? d.reportUrl : typeof d.publicHash === "string" ? d.publicHash : null;
110
+ const ref = typeof d.id === "string" && report ? ` (verification id: ${d.id}, report: ${report})` : "";
111
+ return new Error(base + ref);
112
+ }
113
+
36
114
  async function apiGet(path, key = API_KEY) {
37
- const res = await fetch(`${BASE_URL}${path}`, {
38
- headers: { ...authHeader(key) },
39
- });
40
- const data = await res.json();
41
- if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
42
- return data;
115
+ return fetchJson("GET", path, { key });
43
116
  }
44
117
 
45
118
  async function uploadProof(milestoneId, filename, content, key = API_KEY) {
@@ -47,21 +120,17 @@ async function uploadProof(milestoneId, filename, content, key = API_KEY) {
47
120
  const form = new FormData();
48
121
  form.append("file", blob, filename);
49
122
  form.append("milestoneId", milestoneId);
50
- const res = await fetch(`${BASE_URL}/api/proof/upload`, {
51
- method: "POST",
52
- headers: { ...authHeader(key) },
53
- body: form,
54
- });
55
- const data = await res.json();
56
- if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
57
- return data;
123
+ return fetchJson("POST", "/api/proof/upload", { form, key, deadlineMs: UPLOAD_CLIENT_ABORT_MS });
58
124
  }
59
125
 
60
126
  // Stream verify via SSE — used by the legacy two-step `submit` + `verify --proof <id>` flow
61
127
  async function streamVerify(proofId, key = API_KEY) {
62
128
  const controller = new AbortController();
63
- // Below the 240s maxDuration of /api/verify — see src/lib/latency-budget.ts.
64
- const timeout = setTimeout(() => controller.abort(), 200_000);
129
+ // 235s: one step below the 240s maxDuration of /api/verify, not above its
130
+ // worst case — the tail reaches ~239s, so no value is both. See
131
+ // VERIFY_TAIL_WORST_CASE_MS in src/lib/latency-budget.ts. Cleared after the read loop, not at the fetch
132
+ // (SEC-073), which is what makes the number mean anything.
133
+ const timeout = setTimeout(() => controller.abort(), 235_000);
65
134
 
66
135
  let res;
67
136
  try {
@@ -75,13 +144,47 @@ async function streamVerify(proofId, key = API_KEY) {
75
144
  body: JSON.stringify({ proofId }),
76
145
  signal: controller.signal,
77
146
  });
78
- } finally {
147
+ } catch (e) {
79
148
  clearTimeout(timeout);
149
+ // SEC-073. Now that the deadline is live it can actually fire, and a raw
150
+ // `DOMException: This operation was aborted` names no remedy — worse, per
151
+ // the tail arithmetic in latency-budget.ts the abort can land AFTER
152
+ // releaseMilestone(), so "it failed" may be false about the money.
153
+ if (e && e.name === "AbortError") {
154
+ throw new Error(
155
+ "Verification is still running on the server — this client stopped waiting, it did not fail. " +
156
+ `Poll GET /api/agent/proof-status/${proofId} for the verdict.`
157
+ );
158
+ }
159
+ throw e;
80
160
  }
81
161
 
82
162
  if (!res.ok) {
83
- const data = await res.json().catch(() => ({}));
84
- throw new Error(data.error ?? `HTTP ${res.status}`);
163
+ // SEC-073, second pass. Replacing the fetch's `finally` with a `catch` left
164
+ // THIS exit uncovered: a 409 from /api/verify (already verified, panel in
165
+ // progress, resubmit limit) returned with a 235 s timer still pending. In a
166
+ // long-lived MCP server that holds the event loop and later aborts a fetch
167
+ // that finished minutes ago.
168
+ clearTimeout(timeout);
169
+ const data = await readJsonSafe(res);
170
+ // T01-8. Upload starts verification by itself, so losing the claim to a RUNNING
171
+ // run is the normal case: wait for that run through proof-status instead of
172
+ // failing. This 409's Retry-After is the ~1 h takeover cutoff, never a wait.
173
+ if (res.status === 409 && data.code === "VERIFICATION_IN_PROGRESS" && data.phase === "running") {
174
+ process.stderr.write(" A verification of this proof is already running — waiting for it instead of starting another.\n");
175
+ const budgetMs = Math.max(30_000, Math.min(235_000, ((data.expected_done_in_seconds ?? 200) + 30) * 1000));
176
+ return { attached: true, status: await waitForProof(proofId, budgetMs, key) };
177
+ }
178
+ const code = typeof data.code === "string" ? data.code : null;
179
+ throw Object.assign(new Error(`${data.error ?? `HTTP ${res.status}`}${code ? ` [${code}]` : ""}`), {
180
+ status: res.status,
181
+ code,
182
+ retryAfterSeconds: retryAfterOf(res, data),
183
+ });
184
+ }
185
+ if (!res.body) {
186
+ clearTimeout(timeout);
187
+ throw new Error("Verification stream had no body");
85
188
  }
86
189
 
87
190
  const votes = [];
@@ -117,15 +220,198 @@ async function streamVerify(proofId, key = API_KEY) {
117
220
  }
118
221
  }
119
222
  }
223
+ // SEC-073. Cleared HERE, after the read loop — not in a `finally` around the
224
+ // `fetch`, which resolves on response HEADERS about 12 s into `/api/verify`
225
+ // while the whole 205 s retry ladder arrives afterwards out of `res.body`.
226
+ // At the fetch this was a time-to-first-byte deadline over a twelve-second
227
+ // phase, so its value bounded nothing and could sit below the server's worst
228
+ // case unnoticed. CLIENT_ABORT_MS was raised to 235_000 in the same commit.
229
+ } catch (e) {
230
+ // The deadline can now fire DURING the read — that is the whole point of
231
+ // clearing it below the loop rather than at the fetch. Same translation as
232
+ // the fetch-side catch above.
233
+ if (e && e.name === "AbortError") {
234
+ throw new Error(
235
+ "Verification is still running on the server — this client stopped waiting, it did not fail. " +
236
+ `Poll GET /api/agent/proof-status/${proofId} for the verdict.`
237
+ );
238
+ }
239
+ throw e;
120
240
  } finally {
241
+ clearTimeout(timeout);
121
242
  reader.cancel().catch(() => {});
122
243
  }
123
244
 
124
245
  throw new Error("Stream ended without a complete event");
125
246
  }
126
247
 
248
+ /**
249
+ * Poll proof-status until the proof settles, the run is found dead, or `maxMs` runs
250
+ * out. Returns the last status read, with `stoppedBecause` set when it stopped early:
251
+ * - 401 / 403: waiting does not change the key or the account;
252
+ * - 404: permanent — upload answers only after the proof exists and proof-status
253
+ * reads the primary database, so the proof was deleted or erased;
254
+ * - "interrupted": the run that held the proof died (claimPhase "orphaned").
255
+ * 429 / 503 wait at least Retry-After, never past the budget; anything else is
256
+ * transient. A milestone that is closed, or parked for a human, also settles it.
257
+ */
258
+ async function waitForProof(proofId, maxMs = 100_000, key = API_KEY, pollMs = 5_000) {
259
+ const started = Date.now();
260
+ let waitMs = pollMs;
261
+ let last = null;
262
+ while (Date.now() - started < maxMs) {
263
+ await new Promise((r) => setTimeout(r, waitMs));
264
+ waitMs = pollMs;
265
+ try {
266
+ last = await apiGet(`/api/agent/proof-status/${encodeURIComponent(proofId)}`, key);
267
+ } catch (e) {
268
+ if (e && (e.status === 401 || e.status === 403 || e.status === 404)) {
269
+ return { pending: true, proofId, stoppedBecause: e.status, error: e.message };
270
+ }
271
+ if (e && (e.status === 429 || e.status === 503) && typeof e.retryAfterSeconds === "number") {
272
+ waitMs = Math.min(Math.max(pollMs, e.retryAfterSeconds * 1000), Math.max(0, maxMs - (Date.now() - started)));
273
+ }
274
+ continue;
275
+ }
276
+ if (!last.pending || last.milestoneClosed === true) return { ...last, proofId };
277
+ // 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 };
279
+ if (last.claimPhase === "orphaned") return { ...last, proofId, stoppedBecause: "interrupted" };
280
+ }
281
+ return { ...(last ?? {}), pending: true, proofId, timedOut: true };
282
+ }
283
+
284
+ /** One line for a proof-status result, saying whether funds moved (they did only at COMPLETED). */
285
+ function statusSummary(s) {
286
+ if (s.stoppedBecause === 404) return `⛔ Proof ${s.proofId} no longer exists (deleted or erased). Nothing will verify it.`;
287
+ 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.`;
288
+ if (s.stoppedBecause === "interrupted") {
289
+ // Not "re-run automatically": a claim with no timestamp is never taken over, and
290
+ // the retry cron skips demo contracts and milestones past PROOF_SUBMITTED.
291
+ 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.`;
292
+ }
293
+ if (s.awaitingHumanReview) return "⏳ PENDING_REVIEW — the panel reached no verdict; a human decides. Waiting will not change it.";
294
+ if (s.timedOut) return `⏳ Still verifying. Check again: cascrow status --proof ${s.proofId} --wait`;
295
+ const conf = typeof s.confidence === "number" ? ` (${s.confidence}% confidence)` : "";
296
+ // COMPLETED first: proof-status reports a paid milestone as `milestoneClosed` too
297
+ // (terminalStatus COMPLETED), and the CLOSED arm below would read a release as
298
+ // "moves no funds" — SEC-059's inversion (same order as proofOutcomeLine in both
299
+ // MCP servers). Whether it was THIS proof's YES is `decision`.
300
+ 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).";
302
+ }
303
+ if (s.milestoneClosed === true) {
304
+ return s.terminalReason === "RELEASED_ON_CHAIN"
305
+ ? "⛔ CLOSED (EXPIRED) — the chain shows the builder WAS paid, just not by this verification."
306
+ : `⛔ CLOSED — the milestone is ${s.terminalStatus ?? "closed"}; this verification moved no funds. Do not resubmit to it.`;
307
+ }
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.`;
310
+ if (s.milestoneStatus === "PENDING_REVIEW") return `⏳ PENDING_REVIEW${conf} — manual review required.`;
311
+ if (s.decision === "NO") return `❌ REJECTED${conf}.`;
312
+ return `ℹ️ Decision: ${s.decision ?? "none"}${conf}, milestone ${s.milestoneStatus ?? "unknown"}.`;
313
+ }
314
+
315
+ /**
316
+ * One human-readable line for a verification `complete` frame.
317
+ *
318
+ * Every arm says whether FUNDS MOVED, because that is the one thing a caller cannot
319
+ * find out any other way. `action === "VERIFIED"` alone never means they did: a hold and
320
+ * a compliance block carry their own flag, a success arrives as `COMPLETED`, and a BARE
321
+ * `VERIFIED` is reachable only when the automatic release was attempted and FAILED.
322
+ */
323
+ function outcomeLine(r) {
324
+ const c = r.confidence ?? 0;
325
+ if (r.action === "SUPERSEDED") {
326
+ // `settledAs`: the milestone was closed (e.g. EXPIRED) before this verification could
327
+ // settle it, not beaten by a proof.
328
+ return r.settledAs
329
+ ? `ℹ️ 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.`
330
+ : "ℹ️ SUPERSEDED — another proof on this milestone was decided first, so this verification changed nothing. Your evidence was recorded.";
331
+ }
332
+ if (r.action === "COMPLETED") {
333
+ 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}` : ""}.`;
336
+ }
337
+ if (r.complianceBlocked === true) {
338
+ return `⏸️ VERIFIED (${c}%) — payout on hold pending compliance review. No funds moved.`;
339
+ }
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.`;
342
+ }
343
+ 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.`;
345
+ }
346
+ if (r.timedOut === true) {
347
+ 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.";
348
+ }
349
+ if (r.action === "PENDING_REVIEW") {
350
+ return `⏳ PENDING_REVIEW (${c}%) — manual review required.`;
351
+ }
352
+ if (r.action === "REJECTED") {
353
+ return `❌ REJECTED (${c}%) — resubmit with stronger proof.`;
354
+ }
355
+ return `⚠️ This verification finished with an outcome this CLI does not recognise (action: ${r.action}). NO conclusion was drawn — check the milestone status and upgrade cascrow-cli.`;
356
+ }
357
+
127
358
  // ─── Output helpers ───────────────────────────────────────────────────────────
128
359
 
360
+
361
+ /**
362
+ * One `verify/standalone` call under a client deadline (SEC-073).
363
+ *
364
+ * The route declares `maxDuration = 120`; without this the only signal a hung
365
+ * call gives is the platform tearing the connection down, which reaches the
366
+ * caller as a transport error with nothing in it to act on. One function rather
367
+ * than a copy per shape: the two fetches it wraps are twenty lines apart in the
368
+ * same handler, and a guard reading either in isolation would have reported the
369
+ * pair as protected (F-12/F-60).
370
+ *
371
+ * Mirrors `STANDALONE_CLIENT_ABORT_MS` in `src/lib/latency-budget.ts`. A
372
+ * published package cannot import from the app, so this is a copy by necessity;
373
+ * `standalone-client-deadline.test.ts` is what keeps the FOUR copies equal — this
374
+ * file, the other published package, the SDK, and the owner in
375
+ * `src/lib/latency-budget.ts`.
376
+ */
377
+ const STANDALONE_CLIENT_ABORT_MS = 115_000;
378
+
379
+ async function withStandaloneDeadline(run) {
380
+ const controller = new AbortController();
381
+ const timer = setTimeout(() => controller.abort(), STANDALONE_CLIENT_ABORT_MS);
382
+ try {
383
+ return await run(controller.signal);
384
+ } catch (err) {
385
+ if (err && err.name === "AbortError") {
386
+ throw new Error(
387
+ `Verification did not answer within ${Math.round(STANDALONE_CLIENT_ABORT_MS / 1000)}s. ` +
388
+ "The server may still be working on it — retry, and if it keeps timing out the panel is degraded."
389
+ );
390
+ }
391
+ throw err;
392
+ } finally {
393
+ clearTimeout(timer);
394
+ }
395
+ }
396
+
397
+ /**
398
+ * MACHINE OUTPUT ON stdout, and that is the point of this comment.
399
+ *
400
+ * `SEC-116-INVENTORY` exempted this CLI with the reason "schreibt nach stdout/stderr
401
+ * für einen Menschen". That is true of `err()` below and of the human summaries — and
402
+ * FALSE of this function, which emits `JSON.stringify` for the "CI pipelines and
403
+ * subprocesses" the README names as the target. An exemption whose reason holds for
404
+ * one half of its own subject is F-70, and it is recorded here rather than left in a
405
+ * document nobody reads next to the code.
406
+ *
407
+ * No marker is added, and that is a decision rather than an omission: this CLI's JSON
408
+ * is a published output shape, and prefixing fields inside it would change what every
409
+ * existing parser reads. The honest statement instead — any free text in this output
410
+ * (`reasoning`, `summary`, per-model votes) is MODEL OUTPUT over evidence a
411
+ * counterparty submitted, and reaches you RAW. If you pipe it into a model, treat it
412
+ * as data and not as instructions; the marked surfaces are the two MCP servers and the
413
+ * `/api/agent/verify/*` routes.
414
+ */
129
415
  function print(obj) {
130
416
  console.log(JSON.stringify(obj, null, 2));
131
417
  }
@@ -149,11 +435,14 @@ Commands:
149
435
  create Create a milestone contract
150
436
  fund Activate a milestone (simulation — no on-chain tx)
151
437
  escrow-fund Fund a milestone with real on-chain RLUSD escrow
152
- submit Upload proof text (returns proofId for legacy verify flow)
438
+ submit Upload proof text — verification starts by itself (then: status --proof <id> --wait)
439
+ status Show (or --wait for) the verification result of a proof
153
440
  get Get contract status and milestones
154
441
  join Join a contract as Builder via invite code
155
442
  handoff Send a contract invite to a Builder agent by Agent ID
156
443
  check-invites Check pending contract invites for this agent
444
+ review-queue As funder: list milestones still waiting for your verdict
445
+ review As funder: approve or reject a milestone under manual review
157
446
  me Print this agent's ID
158
447
 
159
448
  Options for each command:
@@ -178,9 +467,13 @@ Options for each command:
178
467
  --commit <sha> optional Git commit SHA or PR URL
179
468
 
180
469
  create
181
- --title <title> required milestone title + acceptance criteria
470
+ --title <title> required short milestone title (printed on the certificate)
182
471
  --days <n> optional deadline days from now (default: 7)
183
472
  --amount <usd> optional RLUSD to lock in escrow (default: 0 = verification-only)
473
+ --criteria <text> optional what the AI panel checks the proof against (max 4000 chars)
474
+ --criteria-file <path> optional the same, read from a file
475
+ --checklist-file <path> optional JSON array of { title, severity?, id? } (max 20) scored item by item on text proofs
476
+ --review-mode <mode> optional AUTO | MANUAL | MANUAL_AUTO — what happens when the panel is unsure (60–85%)
184
477
 
185
478
  fund
186
479
  --contract <id> required
@@ -189,14 +482,18 @@ Options for each command:
189
482
  escrow-fund [REAL on-chain RLUSD — agent's own wallet]
190
483
  --contract <id> required
191
484
  --private-key <key> required EVM private key (funds come from this wallet)
192
- --amount <usd> optional override milestone amount
485
+ --amount <usd> IGNORED the escrowed amount is the milestone's own amountUSD
193
486
  --milestone <id> optional
194
487
 
195
- submit [legacy: use verify instead]
488
+ submit [or use verify for text proof in one call]
196
489
  --milestone <id> required
197
490
  --proof <text> required
198
491
  --file <name> optional filename (default: proof-report.txt)
199
492
 
493
+ status
494
+ --proof <id> required
495
+ --wait optional wait up to ~2 min for the verdict; stops early when waiting cannot help
496
+
200
497
  get
201
498
  --contract <id> required
202
499
 
@@ -226,7 +523,7 @@ Examples:
226
523
  cascrow register --email bot@example.com --password secret123
227
524
 
228
525
  # One-shot: create contract, fund it, then verify a code fix
229
- cascrow create --title "Fix auth bug — all tests must pass" --days 7
526
+ cascrow create --title "Fix auth bug" --criteria "All tests pass; JWT expiry honoured; no new lint errors" --days 7
230
527
  cascrow fund --contract <contractId>
231
528
  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
232
529
 
@@ -242,10 +539,76 @@ Examples:
242
539
  # (Builder agent runs:)
243
540
  cascrow check-invites
244
541
  cascrow join --invite <code>
542
+
543
+ # As the funder: decide what the AI panel could not (confidence 60-85)
544
+ # Poll this — a manual_review.required webhook you missed is a decision nobody
545
+ # makes, and cascrow auto-approves an undecided milestone after 48h.
546
+ cascrow review-queue
547
+ cascrow review --contract <contractId> --decision APPROVE
548
+ cascrow review --contract <contractId> --decision REJECT --milestone <milestoneId>
245
549
  `);
246
550
  process.exit(0);
247
551
  }
248
552
 
553
+ // ─── The bar a milestone is judged against ────────────────────────────────────
554
+ // COPY of normalizeMilestoneChecklist / normalizeCriteria in src/lib/milestone-bar-input.ts
555
+ // (this package cannot import src/). milestone-bar-input-parity.test.ts runs this copy
556
+ // against the owner over the same case matrix.
557
+ const CHECKLIST_MAX_ITEMS = 20;
558
+ const CHECKLIST_TITLE_MAX = 200;
559
+ const CHECKLIST_ID_MAX = 100;
560
+ const CRITERIA_MAX = 4000;
561
+ const CHECKLIST_SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW"];
562
+
563
+ function normalizeMilestoneChecklist(items) {
564
+ if (items === undefined || items === null) return undefined;
565
+ if (!Array.isArray(items)) throw new Error("verificationChecklist: must be an array of { title, severity?, id? }");
566
+ if (items.length === 0) return undefined;
567
+ if (items.length > CHECKLIST_MAX_ITEMS) throw new Error(`verificationChecklist: at most ${CHECKLIST_MAX_ITEMS} items`);
568
+ items.forEach((raw, i) => {
569
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
570
+ throw new Error(`verificationChecklist[${i}]: must be an object with a title`);
571
+ }
572
+ });
573
+ const withId = items.filter((it) => it.id !== undefined).length;
574
+ if (withId !== 0 && withId !== items.length) {
575
+ throw new Error("verificationChecklist: either give every item an id or none (ids are then assigned 1..n)");
576
+ }
577
+ const seen = new Set();
578
+ return items.map((it, i) => {
579
+ const title = typeof it.title === "string" ? it.title.trim() : "";
580
+ if (title.length === 0) throw new Error(`verificationChecklist[${i}]: title is required`);
581
+ if (title.length > CHECKLIST_TITLE_MAX) throw new Error(`verificationChecklist[${i}]: title must be at most ${CHECKLIST_TITLE_MAX} characters`);
582
+ let id = i + 1;
583
+ if (withId) {
584
+ if (typeof it.id !== "number" || !Number.isInteger(it.id) || it.id < 1 || it.id > CHECKLIST_ID_MAX) {
585
+ throw new Error(`verificationChecklist[${i}]: id must be an integer from 1 to ${CHECKLIST_ID_MAX}`);
586
+ }
587
+ if (seen.has(it.id)) throw new Error(`verificationChecklist[${i}]: duplicate id ${it.id}`);
588
+ seen.add(it.id);
589
+ id = it.id;
590
+ }
591
+ const out = { id, title };
592
+ if (it.severity !== undefined && it.severity !== null && it.severity !== "") {
593
+ const sev = typeof it.severity === "string" ? it.severity.trim().toUpperCase() : "";
594
+ if (!CHECKLIST_SEVERITIES.includes(sev)) {
595
+ throw new Error(`verificationChecklist[${i}]: severity must be one of ${CHECKLIST_SEVERITIES.join(", ")}`);
596
+ }
597
+ out.severity = sev;
598
+ }
599
+ return out;
600
+ });
601
+ }
602
+
603
+ function normalizeCriteria(value) {
604
+ if (value === undefined || value === null) return undefined;
605
+ if (typeof value !== "string") throw new Error("verificationCriteria: must be a string");
606
+ const v = value.trim();
607
+ if (v.length === 0) return undefined;
608
+ if (v.length > CRITERIA_MAX) throw new Error(`verificationCriteria: at most ${CRITERIA_MAX} characters (got ${v.length})`);
609
+ return v;
610
+ }
611
+
249
612
  // ─── Argument parser ──────────────────────────────────────────────────────────
250
613
 
251
614
  function parseArgs(argv) {
@@ -282,8 +645,8 @@ async function cmdRegister(args) {
282
645
  keyName: args["key-name"] ?? "cli",
283
646
  }),
284
647
  });
285
- const data = await res.json();
286
- if (!res.ok) err(data.error ?? `HTTP ${res.status}`);
648
+ const data = await readJsonSafe(res);
649
+ if (!res.ok || data.code === "UPSTREAM_NON_JSON") err(`${data.error ?? `HTTP ${res.status}`}${data.code ? ` [${data.code}]` : ""}`);
287
650
  print(data);
288
651
  console.error("\nSave your API key and set it:\n export CASCROW_API_KEY=" + data.apiKey + "\n");
289
652
  }
@@ -317,18 +680,46 @@ async function cmdVerifyWork(args) {
317
680
  form.append("taskDescription", args.task);
318
681
  form.append("image", new Blob([imageBuffer], { type: imageMimeType }), args.image.split("/").pop());
319
682
 
320
- const res = await fetch(`${BASE_URL}/api/verify/standalone`, {
321
- method: "POST",
322
- headers: { ...authHeader(API_KEY) },
323
- body: form,
683
+ const { res, data: body } = await withStandaloneDeadline(async (signal) => {
684
+ const r = await fetch(`${BASE_URL}/api/verify/standalone`, {
685
+ method: "POST",
686
+ headers: { ...authHeader(API_KEY) },
687
+ body: form,
688
+ signal,
689
+ });
690
+ // The body is read INSIDE the deadline. A response whose headers arrived
691
+ // and whose body then stalls is otherwise unbounded — a deadline that ends
692
+ // at the headers bounds time-to-first-byte and nothing else (SEC-073).
693
+ return { res: r, data: await readJsonSafe(r) };
324
694
  });
325
- data = await res.json();
326
- if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
695
+ if (!res.ok) throw standaloneFailure(res.status, body);
696
+ // Assigned to the OUTER `data`, not shadowing it: `const { data }` inside the
697
+ // branch left the function-scoped variable undefined and the next line read
698
+ // `data.decision` off it. `node --check` accepts a shadow happily.
699
+ data = body;
327
700
  } else {
328
701
  const body = { taskDescription: args.task };
329
702
  if (args.pr) body.prUrl = args.pr;
330
703
  if (args.code) body.codeText = args.code;
331
- data = await apiPost("/api/verify/standalone", body);
704
+ // Not `apiPost`: its generic throw drops the row reference the 500 carries.
705
+ const { res, data: answer } = await withStandaloneDeadline(async (signal) => {
706
+ const r = await fetch(`${BASE_URL}/api/verify/standalone`, {
707
+ method: "POST",
708
+ headers: { ...authHeader(API_KEY), "Content-Type": "application/json" },
709
+ body: JSON.stringify(body),
710
+ signal,
711
+ });
712
+ // The body is read INSIDE the deadline. A response whose headers arrived
713
+ // and whose body then stalls is otherwise unbounded — a deadline that ends
714
+ // at the headers bounds time-to-first-byte and nothing else (SEC-073).
715
+ return { res: r, data: await readJsonSafe(r) };
716
+ });
717
+ if (!res.ok) throw standaloneFailure(res.status, answer);
718
+ // Assigned to the OUTER `data`, not shadowing it: `const { data }` inside the
719
+ // branch left the function-scoped variable undefined and the next line read
720
+ // `data.decision` off it. `answer` rather than `body` because this branch
721
+ // already has a `body` — the REQUEST payload — twelve lines up.
722
+ data = answer;
332
723
  }
333
724
 
334
725
  const votes = Array.isArray(data.modelVotes) ? data.modelVotes : [];
@@ -359,25 +750,60 @@ async function cmdVerifyWork(args) {
359
750
  // Auto-releases funds and mints NFT on approval — no separate `verify --proof <id>` needed.
360
751
  async function cmdVerify(args) {
361
752
  if (!API_KEY) err("CASCROW_API_KEY is not set");
362
- if (!args.contract) err("--contract is required");
363
753
  if (!args.proof) err("--proof is required");
364
754
 
365
- // Legacy mode: --proof looks like a proof ID (prf_...) and no --contract — use stream verify
755
+ // Legacy mode: --proof looks like a proof ID (prf_...) and no --contract — use stream verify.
756
+ // Checked BEFORE the --contract requirement below: that check used to run
757
+ // first and process.exit(1) whenever --contract was missing, which made
758
+ // this branch (and streamVerify()) permanently unreachable — exactly the
759
+ // invocation cmdSubmit's own output tells users to run.
366
760
  if (!args.contract && args.proof && !args.proof.includes(" ")) {
367
761
  process.stderr.write("\nRunning AI verification (5 models)...\n\n");
368
762
  const result = await streamVerify(args.proof);
369
- const passed = result.decision === "YES" && (result.confidence ?? 0) >= 85;
763
+ // The run was already going (usually the one upload started): print its outcome
764
+ // as proof-status reports it — the stream's frame never arrived here.
765
+ if (result && result.attached) {
766
+ print(result.status);
767
+ process.stderr.write(statusSummary(result.status) + "\n");
768
+ return;
769
+ }
770
+ // SEC-055/058/059 at the third published surface. `passed` used to be
771
+ // `decision === "YES" && confidence >= 85`, i.e. a re-implementation of the
772
+ // three-tier rule from the PROOF's verdict — which answers a different question
773
+ // from the one a caller is asking. `COMPLETED`, `SUPERSEDED`, a compliance block
774
+ // and a release hold all rendered as verified, and a panel outage
775
+ // (`decision: "PENDING"`) rendered as "NOT VERIFIED 0%", i.e. our own outage read
776
+ // back to the builder as a rejection.
777
+ //
778
+ // `passed` now means MONEY MOVED, and only `COMPLETED` says that.
779
+ const released = result.action === "COMPLETED";
780
+ const passed = released;
370
781
  print({
371
782
  decision: result.decision,
372
783
  confidence: result.confidence,
373
784
  passed,
374
785
  action: result.action,
786
+ released,
787
+ alreadyReleased: result.alreadyReleased === true,
788
+ superseded: result.action === "SUPERSEDED",
789
+ // With SUPERSEDED: the TERMINAL status the row was already in (EXPIRED, COMPLETED),
790
+ // or null when another proof won it. It used to reach only the stderr line from
791
+ // `outcomeLine`, so a machine caller reading this JSON could not tell "closed —
792
+ // nothing moved, do not resubmit" from "another proof won" (round-3 pass 2).
793
+ settledAs: typeof result.settledAs === "string" ? result.settledAs : null,
794
+ complianceBlocked: result.complianceBlocked === true,
795
+ releaseHoldUntil: result.releaseHoldUntil ?? null,
796
+ panelUnavailable: result.timedOut === true,
797
+ txHash: result.txHash ?? null,
375
798
  reasoning: result.reasoning,
376
799
  modelVotes: (result.votes ?? []).map(v => ({ model: v.model, decision: v.decision, confidence: v.confidence })),
377
800
  });
801
+ process.stderr.write(outcomeLine(result) + "\n");
378
802
  return;
379
803
  }
380
804
 
805
+ if (!args.contract) err("--contract is required");
806
+
381
807
  // One-shot mode: text proof → /api/mcp/submit → verdict + auto-release
382
808
  const links = args.links
383
809
  ? args.links.split(",").map(l => l.trim()).filter(Boolean)
@@ -393,10 +819,20 @@ async function cmdVerify(args) {
393
819
  contract_id: args.contract,
394
820
  milestone_id: args.milestone ?? undefined,
395
821
  evidence,
396
- });
397
-
398
- const verdict = data.verdict ?? "unknown";
822
+ }, API_KEY, MCP_SUBMIT_CLIENT_ABORT_MS);
823
+
824
+ // `decided: "elsewhere"` and `panelUnavailable` are 200 responses that carry NO
825
+ // `verdict` key, so `data.verdict ?? "unknown"` fell to the final arm below and told
826
+ // the builder "❌ REJECTED — resubmit with stronger proof" for a milestone that may
827
+ // well have been approved and paid (SEC-055). Classified before the verdict is read.
828
+ const verdict = data.decided === "elsewhere"
829
+ ? "decided_elsewhere"
830
+ : data.panelUnavailable
831
+ ? "panel_unavailable"
832
+ : (data.verdict ?? "unknown");
399
833
  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;
400
836
 
401
837
  // Print model votes if available
402
838
  const votes = Array.isArray(data.model_votes) ? data.model_votes : [];
@@ -406,21 +842,54 @@ async function cmdVerify(args) {
406
842
  }
407
843
  process.stderr.write("\n");
408
844
 
845
+ // With `decided: "elsewhere"`: the TERMINAL status the milestone was already in. EXPIRED
846
+ // is a funder cancel or the deadline, not "the funder's review, or a concurrent
847
+ // submission", and resubmitting to it is pointless.
848
+ const settledAs = verdict === "decided_elsewhere" && typeof data.settledAs === "string" ? data.settledAs : null;
849
+
409
850
  let statusLine;
410
- if (verdict === "approved") {
851
+ if (settledAs) {
852
+ statusLine =
853
+ `ℹ️ This milestone was closed (${settledAs}) before this submission could settle it, so this submission ` +
854
+ "changed nothing and moved no funds itself. Your evidence was recorded. Do not resubmit to this milestone";
855
+ } else if (verdict === "decided_elsewhere") {
856
+ statusLine =
857
+ "ℹ️ This milestone was decided by another request while your proof was being verified — " +
858
+ "by the funder's review, or by a concurrent submission. Your evidence was recorded. " +
859
+ "Check the milestone status before resubmitting";
860
+ } else if (verdict === "panel_unavailable") {
861
+ statusLine =
862
+ "⏳ PENDING_REVIEW — the verification panel could not reach a verdict (too few models " +
863
+ "answered). This is NOT a rejection and NOT a judgement of your work; a human decides, " +
864
+ "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
+ } else if (verdict === "approved") {
411
870
  statusLine = `✅ VERIFIED (${confidence}% confidence)`;
412
871
  if (data.on_chain_url) statusLine += `\n Proof: ${data.on_chain_url}`;
413
872
  } else if (verdict === "pending_review") {
414
873
  statusLine = `⏳ PENDING_REVIEW (${confidence}% confidence) — manual review triggered`;
415
- } else {
874
+ } else if (verdict === "rejected") {
416
875
  statusLine = `❌ REJECTED (${confidence}% confidence) — resubmit with stronger proof`;
876
+ } else {
877
+ // Not a rejection: an outcome this version does not know. Drawing no conclusion is
878
+ // the one answer that cannot be wrong, and it makes the next value visible instead
879
+ // of silently a rejection.
880
+ statusLine =
881
+ `⚠️ This submission finished with an outcome this CLI does not recognise (verdict: ${verdict}). ` +
882
+ "No conclusion was drawn — check the milestone status and upgrade cascrow-cli";
417
883
  }
418
884
  process.stderr.write(statusLine + "\n\n");
419
885
 
420
886
  print({
421
887
  verdict,
422
888
  confidence,
423
- passed: verdict === "approved",
889
+ // MONEY MOVED — not "the verdict was approved". A scheduled payout has not happened.
890
+ passed: verdict === "approved" && !releaseHoldUntil,
891
+ release_hold_until: releaseHoldUntil,
892
+ ...(settledAs ? { settled_as: settledAs } : {}),
424
893
  reasoning: data.reasoning ?? "",
425
894
  on_chain_url: data.on_chain_url ?? null,
426
895
  proof_id: data.proof_id ?? null,
@@ -438,12 +907,40 @@ async function cmdCreate(args) {
438
907
  deadline.setDate(deadline.getDate() + deadlineDays);
439
908
  const amountUSD = parseFloat(args.amount ?? "0");
440
909
 
910
+ // T07. The bar the panel judges against — kept OUT of the title, which is printed
911
+ // on the public certificate. Normalised by a copy of src/lib/milestone-bar-input.ts
912
+ // (milestone-bar-input-parity.test.ts runs it against the owner).
913
+ let criteriaRaw = typeof args.criteria === "string" ? args.criteria : undefined;
914
+ if (typeof args["criteria-file"] === "string") criteriaRaw = readFileSync(args["criteria-file"], "utf8");
915
+ let checklistRaw;
916
+ if (typeof args["checklist-file"] === "string") {
917
+ try {
918
+ checklistRaw = JSON.parse(readFileSync(args["checklist-file"], "utf8"));
919
+ } catch (e) {
920
+ err(`--checklist-file: not a JSON array of { title, severity?, id? } (${e.message})`);
921
+ }
922
+ }
923
+ const reviewMode = typeof args["review-mode"] === "string" ? args["review-mode"].toUpperCase() : undefined;
924
+ if (reviewMode !== undefined && !["AUTO", "MANUAL", "MANUAL_AUTO"].includes(reviewMode)) {
925
+ err("--review-mode must be AUTO, MANUAL or MANUAL_AUTO");
926
+ }
927
+ let criteria, checklist;
928
+ try {
929
+ criteria = normalizeCriteria(criteriaRaw);
930
+ checklist = normalizeMilestoneChecklist(checklistRaw);
931
+ } catch (e) {
932
+ err(e.message);
933
+ }
934
+
441
935
  const data = await apiPost("/api/contracts", {
442
936
  milestones: [{
443
937
  title: args.title,
444
938
  amountUSD,
445
939
  cancelAfter: deadline.toISOString(),
940
+ ...(criteria !== undefined ? { verificationCriteria: criteria } : {}),
941
+ ...(checklist !== undefined ? { verificationChecklist: checklist } : {}),
446
942
  }],
943
+ ...(reviewMode !== undefined ? { agentReviewMode: reviewMode } : {}),
447
944
  });
448
945
 
449
946
  const contractUrl = `${BASE_URL}/contract/${data.contractId}`;
@@ -474,7 +971,14 @@ async function cmdEscrowFund(args) {
474
971
  agentPrivateKey: args["private-key"],
475
972
  };
476
973
  if (args.milestone) body.milestoneId = args.milestone;
477
- if (args.amount) body.amountUSD = parseFloat(args.amount);
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
+ }
478
982
 
479
983
  process.stderr.write("\nFunding milestone on-chain (approve + fundMilestone)...\n");
480
984
  const data = await apiPost("/api/agent/escrow-fund", body);
@@ -494,7 +998,25 @@ async function cmdSubmit(args) {
494
998
  args.proof
495
999
  );
496
1000
  print(data);
497
- process.stderr.write(`\nProof submitted. Run: cascrow verify --proof ${data.proofId}\n\n`);
1001
+ // Upload starts verification by itself — `verify --proof` would only race it.
1002
+ process.stderr.write(`\nProof submitted; verification has started. Wait for the verdict: cascrow status --proof ${data.proofId} --wait\n\n`);
1003
+ }
1004
+
1005
+ async function cmdStatus(args) {
1006
+ if (!API_KEY) err("CASCROW_API_KEY is not set");
1007
+ if (!args.proof) err("--proof is required");
1008
+ if (args.wait) {
1009
+ process.stderr.write("\nWaiting for the verification to settle (up to ~2 min)...\n\n");
1010
+ const s = await waitForProof(args.proof, 120_000);
1011
+ print(s);
1012
+ process.stderr.write("\n" + statusSummary(s) + "\n\n");
1013
+ return;
1014
+ }
1015
+ const data = await apiGet(`/api/agent/proof-status/${encodeURIComponent(args.proof)}`);
1016
+ print(data);
1017
+ process.stderr.write(
1018
+ "\n" + (data.pending && data.milestoneClosed !== true ? `⏳ Still verifying${data.claimPhase === "orphaned" ? " — the run was interrupted" : ""}.` : statusSummary({ ...data, proofId: args.proof })) + "\n\n"
1019
+ );
498
1020
  }
499
1021
 
500
1022
  async function cmdGet(args) {
@@ -540,6 +1062,62 @@ async function cmdCheckInvites() {
540
1062
  }
541
1063
  }
542
1064
 
1065
+ /**
1066
+ * The funder's pending-verdict queue, and the verdict itself.
1067
+ *
1068
+ * Both go through the routes every other surface uses — `GET
1069
+ * /api/agent/review-queue` and `POST /api/contracts/review` — which ask the same
1070
+ * owner module, so the queue this prints and the queue the door accepts a
1071
+ * verdict on cannot come apart (F-22).
1072
+ *
1073
+ * `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
1077
+ * its reader cannot act on names a remedy they are unable to perform (F-72).
1078
+ */
1079
+ async function cmdReviewQueue() {
1080
+ if (!API_KEY) err("CASCROW_API_KEY is not set");
1081
+ const data = await apiGet("/api/agent/review-queue");
1082
+ const contracts = data.contracts ?? [];
1083
+ print(data);
1084
+ if (contracts.length === 0) {
1085
+ process.stderr.write("\nNo milestones are waiting for your verdict.\n\n");
1086
+ return;
1087
+ }
1088
+ const lines = contracts.map((c) => {
1089
+ const pending = c.pendingMilestones ?? [];
1090
+ const ms =
1091
+ pending.length === 1
1092
+ ? `milestone ${pending[0].milestoneId}`
1093
+ : pending.length > 1
1094
+ ? `${pending.length} milestones pending — pass --milestone <id>`
1095
+ : "contract-level review — omit --milestone";
1096
+ return ` ${c.contractId} ${c.amountUSD} RLUSD (${ms})`;
1097
+ }).join("\n");
1098
+ const more = data.truncated ? `\n(${data.total} in total — this list is capped.)` : "";
1099
+ process.stderr.write(
1100
+ `\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"
1102
+ );
1103
+ }
1104
+
1105
+ async function cmdReview(args) {
1106
+ if (!API_KEY) err("CASCROW_API_KEY is not set");
1107
+ const contractId = args.contract;
1108
+ const decision = (args.decision ?? "").toUpperCase();
1109
+ if (!contractId) err("--contract <id> is required (see: cascrow review-queue)");
1110
+ if (decision !== "APPROVE" && decision !== "REJECT") err("--decision must be APPROVE or REJECT");
1111
+ const body = args.milestone ? { contractId, decision, milestoneId: args.milestone } : { contractId, decision };
1112
+ const data = await apiPost("/api/contracts/review", body);
1113
+ print(data);
1114
+ const what =
1115
+ decision === "APPROVE"
1116
+ ? "approved — the escrow is being released to the builder"
1117
+ : "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`);
1119
+ }
1120
+
543
1121
  async function cmdMe() {
544
1122
  if (!API_KEY) err("CASCROW_API_KEY is not set");
545
1123
  const data = await apiGet("/api/agent/me");
@@ -565,10 +1143,13 @@ try {
565
1143
  case "fund": await cmdFund(args); break;
566
1144
  case "escrow-fund": await cmdEscrowFund(args); break;
567
1145
  case "submit": await cmdSubmit(args); break;
1146
+ case "status": await cmdStatus(args); break;
568
1147
  case "get": await cmdGet(args); break;
569
1148
  case "join": await cmdJoin(args); break;
570
1149
  case "handoff": await cmdHandoff(args); break;
571
1150
  case "check-invites": await cmdCheckInvites(); break;
1151
+ case "review-queue": await cmdReviewQueue(); break;
1152
+ case "review": await cmdReview(args); break;
572
1153
  case "me": await cmdMe(); break;
573
1154
  default:
574
1155
  console.error(`Unknown command: ${command}\n`);