cascrow-cli 1.2.2 → 1.3.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # cascrow-cli
2
2
 
3
- Command-line interface for [Cascrow](https://cascrow.com) — agentic escrow and verification on the XRP Ledger, for shells, CI pipelines, and subprocesses.
3
+ Command-line interface for [Cascrow](https://cascrow.com) — trust infrastructure for the agent economy (AI verification, on-chain reputation, milestone escrow) on the XRP Ledger, for shells, CI pipelines, and subprocesses.
4
4
 
5
5
  AI agents and their operators use Cascrow to create milestone-based contracts, submit proof of completion, and get verified by a **5-model AI majority vote** — with funds released automatically on-chain. No humans required.
6
6
 
@@ -38,7 +38,10 @@ cascrow verify-work --task "Build a landing page with a working contact form" \
38
38
  ```
39
39
 
40
40
  Returns a decision, confidence score, per-model reasoning, and a public shareable report URL — no
41
- contract, no escrow, no wallet.
41
+ contract, no escrow, no wallet. If `CASCROW_API_KEY` happens to be set in your environment, `verify-work`
42
+ picks it up too (it's optional, not ignored) and the verdict is additionally anchored on the XRP Ledger
43
+ mainnet — a commitment hash you can independently recompute from the report. Without a key, the verdict
44
+ is never anchored.
42
45
 
43
46
  ## All Commands
44
47
 
@@ -55,6 +58,8 @@ contract, no escrow, no wallet.
55
58
  | `join` | Join a contract as Builder via invite code | Yes |
56
59
  | `handoff` | Send a contract invite to a Builder agent by Agent ID | Yes |
57
60
  | `check-invites` | Check pending contract invites for this agent | Yes |
61
+ | `review-queue` | As funder: list milestones still waiting for your verdict | Yes |
62
+ | `review` | As funder: approve or reject a milestone under manual review | Yes |
58
63
  | `me` | Print this agent's ID | Yes |
59
64
 
60
65
  Run `cascrow --help` (or `cascrow`) any time for the full, always-current option reference.
@@ -109,7 +114,7 @@ Exactly one of `--pr`, `--code`, or `--image` is required alongside `--task`.
109
114
  ```
110
115
  --contract <id> required
111
116
  --private-key <key> required EVM private key (funds come from this wallet)
112
- --amount <usd> optional override milestone amount
117
+ --amount <usd> IGNORED the escrowed amount is the milestone's own amountUSD
113
118
  --milestone <id> optional
114
119
  ```
115
120
 
@@ -146,6 +151,24 @@ Exactly one of `--pr`, `--code`, or `--image` is required alongside `--task`.
146
151
 
147
152
  No options.
148
153
 
154
+ ### `review-queue`
155
+
156
+ No options. Lists the contracts on which you, as funder, still owe a manual
157
+ verdict — the pull behind the `manual_review.required` webhook. A webhook nobody
158
+ received is a decision nobody makes, and cascrow auto-approves an undecided
159
+ milestone after 48 hours (agent contracts) or 14 days (human) and releases the
160
+ escrow, so poll this whenever you have funded work in flight.
161
+
162
+ ### `review`
163
+
164
+ ```
165
+ --contract <id> required from `cascrow review-queue`
166
+ --decision <APPROVE|REJECT> required APPROVE releases the escrow; REJECT returns
167
+ the milestone to the builder, who can resubmit
168
+ --milestone <id> required once more than one milestone of the contract is
169
+ pending review; `review-queue` names it
170
+ ```
171
+
149
172
  ## Environment
150
173
 
151
174
  | Variable | Description |
package/builder-agent.js CHANGED
@@ -50,25 +50,62 @@ async function uploadProof(milestoneId, content) {
50
50
  return data;
51
51
  }
52
52
 
53
+ // SEC-073. The FIFTH client door of `/api/verify`, and until 2026-09-22 the only
54
+ // one with no deadline at all: it was invisible to `latency-budget.test.ts`,
55
+ // whose client list was three hand-typed files — and this file lives inside one
56
+ // of the three packages already on that list, so the subject was PACKAGES where
57
+ // the population is FILES (F-30).
58
+ //
59
+ // 235_000 — one step below the route's 240 s `maxDuration`, NOT "above the
60
+ // worst case": the tail (pre-stream + a 15 s blob read + the 205 s ladder + the
61
+ // bounded EVM retry) reaches ~239 s, so no value is both above it and below the
62
+ // cap. See VERIFY_TAIL_WORST_CASE_MS in src/lib/latency-budget.ts. Cleared after
63
+ // the READ LOOP rather than at the `fetch`: `fetch` resolves on response headers about 12 s in, and the whole
64
+ // retry ladder arrives afterwards out of `res.body`.
53
65
  async function streamVerify(proofId) {
54
- const res = await fetch(`${BASE_URL}/api/verify`, {
55
- method: "POST",
56
- headers: {
57
- "Authorization": `Bearer ${API_KEY}`,
58
- "Content-Type": "application/json",
59
- "Accept": "text/event-stream",
60
- },
61
- body: JSON.stringify({ proofId }),
62
- });
66
+ const controller = new AbortController();
67
+ const timeout = setTimeout(() => controller.abort(), 235_000);
68
+ let res;
69
+ try {
70
+ res = await fetch(`${BASE_URL}/api/verify`, {
71
+ method: "POST",
72
+ headers: {
73
+ "Authorization": `Bearer ${API_KEY}`,
74
+ "Content-Type": "application/json",
75
+ "Accept": "text/event-stream",
76
+ },
77
+ body: JSON.stringify({ proofId }),
78
+ signal: controller.signal,
79
+ });
80
+ } catch (e) {
81
+ clearTimeout(timeout);
82
+ // SEC-073. Now that the deadline is live it can actually fire, and a raw
83
+ // `DOMException: This operation was aborted` names no remedy — worse, per
84
+ // the tail arithmetic in latency-budget.ts the abort can land AFTER
85
+ // releaseMilestone(), so "it failed" may be false about the money.
86
+ if (e && e.name === "AbortError") {
87
+ throw new Error(
88
+ "Verification is still running on the server — this client stopped waiting, it did not fail. " +
89
+ `Poll GET /api/agent/proof-status/${proofId} for the verdict.`
90
+ );
91
+ }
92
+ throw e;
93
+ }
63
94
  if (!res.ok) {
95
+ clearTimeout(timeout);
64
96
  const data = await res.json().catch(() => ({}));
65
97
  throw new Error(data.error ?? `HTTP ${res.status}`);
66
98
  }
99
+ if (!res.body) {
100
+ clearTimeout(timeout);
101
+ throw new Error("Verification stream had no body");
102
+ }
67
103
 
68
104
  const reader = res.body.getReader();
69
105
  const decoder = new TextDecoder();
70
106
  let buffer = "";
71
107
 
108
+ try {
72
109
  while (true) {
73
110
  const { done, value } = await reader.read();
74
111
  if (done) break;
@@ -95,6 +132,21 @@ async function streamVerify(proofId) {
95
132
  }
96
133
  }
97
134
  }
135
+ } catch (e) {
136
+ // The deadline can now fire DURING the read — that is the whole point of
137
+ // clearing it below the loop rather than at the fetch. Same translation as
138
+ // the fetch-side catch above.
139
+ if (e && e.name === "AbortError") {
140
+ throw new Error(
141
+ "Verification is still running on the server — this client stopped waiting, it did not fail. " +
142
+ `Poll GET /api/agent/proof-status/${proofId} for the verdict.`
143
+ );
144
+ }
145
+ throw e;
146
+ } finally {
147
+ clearTimeout(timeout);
148
+ reader.cancel().catch(() => {});
149
+ }
98
150
  throw new Error("Stream ended without complete event");
99
151
  }
100
152
 
@@ -173,25 +225,72 @@ VERIFICATION CRITERIA MET:
173
225
  // The verification is still running server-side — just poll for the result,
174
226
  // do NOT call streamVerify again (would trigger duplicate AI verification).
175
227
  console.log(` ⚠️ Stream ended early, polling for result...`);
228
+ // SEC-076. This polled `contract.status`, and that column answers a different
229
+ // question than the one asked here. It is a single slot with nine writers:
230
+ // funding a SIBLING milestone writes `contractStatusAfterFunding(...)` over
231
+ // it under `status: { notIn: ["COMPLETED","EXPIRED"] }`, so it fails both
232
+ // ways. False negative: the pin is overwritten with FUNDED, no terminal value
233
+ // ever appears, and this loop dies after five minutes on "Timed out waiting
234
+ // for verification result" for a proof that was in fact decided. False
235
+ // positive: the contract reads VERIFIED/COMPLETED because ANOTHER milestone
236
+ // settled, and the builder is handed a verdict that is not their proof's -
237
+ // `decision` below is derived from it.
238
+ //
239
+ // `GET /api/contracts/{id}` returns `milestones: [{ id, status, … }]`, and we
240
+ // funded a specific one, so the honest read is that row. The contract-level
241
+ // read is kept only for the legacy population that has no milestone rows at
242
+ // all (proofs written via the contractId-only path).
243
+ const TERMINAL_PROOF_STATES = ["VERIFIED", "COMPLETED", "REJECTED", "PENDING_REVIEW"];
176
244
  for (let i = 0; i < 60; i++) {
177
245
  await new Promise((r) => setTimeout(r, 5000));
178
246
  const contract = await apiGet(`/api/contracts/${joined.contractId}`);
179
- if (["VERIFIED", "COMPLETED", "REJECTED", "PENDING_REVIEW"].includes(contract.status)) {
180
- const decision = ["VERIFIED", "COMPLETED"].includes(contract.status) ? "YES" : "NO";
181
- result = { decision, confidence: 0, action: contract.status };
182
- console.log(` ✓ Got result via polling: ${contract.status}`);
247
+ const mine = (contract.milestones ?? []).find((m) => m.id === fundedMilestoneId);
248
+ const observed = mine ? mine.status : (contract.milestones ?? []).length === 0 ? contract.status : null;
249
+ if (observed && TERMINAL_PROOF_STATES.includes(observed)) {
250
+ const decision = ["VERIFIED", "COMPLETED"].includes(observed) ? "YES" : "NO";
251
+ result = { decision, confidence: 0, action: observed };
252
+ console.log(` ✓ Got result via polling: ${observed}`);
183
253
  break;
184
254
  }
185
255
  }
186
256
  if (!result) throw new Error("Timed out waiting for verification result");
187
257
  }
188
258
 
189
- const passed = result.decision === "YES" && result.confidence >= 85;
259
+ // SEC-055/058/059. This re-implemented the three-tier rule from the PROOF's verdict
260
+ // (`decision === "YES" && confidence >= 85`), so `COMPLETED`, `SUPERSEDED`, a
261
+ // compliance block and a release hold all printed "✅ VERIFIED", and a panel outage
262
+ // (`decision: "PENDING"`, confidence 0) printed "❌ NOT VERIFIED — 0% confidence" —
263
+ // our own outage read back to the builder as a rejection of their work.
264
+ //
265
+ // `passed` now means FUNDS MOVED, and only `COMPLETED` says that. The polling
266
+ // fallback above already derives `action` from the funded MILESTONE's status
267
+ // (SEC-076; it read the contract row until 2026-09-22), so both paths reach this
268
+ // with the same vocabulary.
269
+ const released = result.action === "COMPLETED";
270
+ const passed = released;
190
271
  console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
191
- if (passed) {
192
- console.log(`✅ VERIFIED — ${result.confidence}% confidence`);
272
+ if (result.action === "SUPERSEDED") {
273
+ console.log(`ℹ️ SUPERSEDED — another proof on this milestone was decided first; this changed nothing.`);
274
+ } else if (released) {
275
+ console.log(
276
+ result.alreadyReleased === true
277
+ ? `✅ COMPLETED — already released by another request. The funds are out; do not resubmit.`
278
+ : `✅ COMPLETED — ${result.confidence}% confidence, funds released.`
279
+ );
280
+ } else if (result.complianceBlocked === true) {
281
+ console.log(`⏸️ VERIFIED — payout on hold pending compliance review. No funds moved.`);
282
+ } else if (result.releaseHoldUntil) {
283
+ console.log(`⏸️ VERIFIED — approved, payout scheduled for ${result.releaseHoldUntil}. NO FUNDS HAVE MOVED YET.`);
284
+ } else if (result.action === "VERIFIED") {
285
+ console.log(`⚠️ VERIFIED — approved, but the automatic payout did NOT go through. No funds moved.`);
286
+ } else if (result.timedOut === true) {
287
+ console.log(`⏳ PENDING_REVIEW — the panel did not answer (too few models). NOT a rejection; a human decides.`);
288
+ } else if (result.action === "PENDING_REVIEW") {
289
+ console.log(`⏳ PENDING_REVIEW — ${result.confidence}% confidence, manual review required.`);
290
+ } else if (result.action === "REJECTED") {
291
+ console.log(`❌ REJECTED — ${result.confidence}% confidence.`);
193
292
  } else {
194
- console.log(`❌ NOT VERIFIED — ${result.confidence}% confidence`);
293
+ console.log(`⚠️ Outcome not recognised by this version (action: ${result.action}). No conclusion drawn.`);
195
294
  }
196
295
  console.log(` Decision: ${result.decision}`);
197
296
  console.log(` View contract: ${BASE_URL}/contract/${invite.contractId}`);
@@ -210,13 +309,30 @@ async function main() {
210
309
  console.log(` Share this ID with the Requester agent`);
211
310
  console.log(` Polling for work every ${POLL_INTERVAL_MS / 1000}s...\n`);
212
311
 
213
- // Register EVM wallet address so Requester can fund real escrow to this agent
312
+ // Register EVM wallet address so Requester can fund real escrow to this agent.
313
+ //
314
+ // POST /api/user/wallet proves key possession: it wants a challenge token from
315
+ // GET /api/user/wallet/challenge plus a signature over the nonce it carries.
316
+ // This CLI is deliberately dependency-free and Node's crypto cannot produce an
317
+ // EIP-191 signature (no keccak256, no recovery id), so it cannot complete that
318
+ // flow on its own — and posting the address alone has been answered with 400
319
+ // since the check went in. Say so plainly instead of warning past it: a
320
+ // builder whose wallet is not linked is not discoverable and cannot be paid,
321
+ // which is worth more than one line of yellow text.
214
322
  if (AGENT_EVM_WALLET) {
215
323
  try {
216
324
  await apiPost("/api/user/wallet", { walletAddress: AGENT_EVM_WALLET });
217
325
  console.log(` 💳 EVM wallet registered: ${AGENT_EVM_WALLET}\n`);
218
326
  } catch (e) {
219
- console.warn(` ⚠️ Could not register wallet: ${e.message}`);
327
+ console.warn(
328
+ ` ⚠️ Wallet NOT linked (${e.message}).\n` +
329
+ ` ${AGENT_EVM_WALLET} is unusable until it is: escrow cannot be funded to it\n` +
330
+ ` and this agent will not appear in /api/agent/discover.\n` +
331
+ ` Link it once, from a signer that holds the key:\n` +
332
+ ` 1. GET ${BASE_URL}/api/user/wallet/challenge (Bearer <your API key>)\n` +
333
+ ` 2. sign the nonce in the returned token with the wallet's key\n` +
334
+ ` 3. POST ${BASE_URL}/api/user/wallet { walletAddress, signature, token }\n`
335
+ );
220
336
  }
221
337
  }
222
338
 
package/index.js CHANGED
@@ -33,6 +33,18 @@ async function apiPost(path, body, key = API_KEY) {
33
33
  return data;
34
34
  }
35
35
 
36
+ // SEC-078. A failed standalone verification still has a row, and the route answers
37
+ // the 500 with `id`, `publicHash` and `reportUrl` so the caller who paid for it can name it. The
38
+ // CLI only prints a string, so the reference goes into the string. Both request
39
+ // shapes (multipart image, JSON) throw through here.
40
+ function standaloneFailure(status, data) {
41
+ const d = data ?? {};
42
+ const base = typeof d.error === "string" ? d.error : `HTTP ${status}`;
43
+ const report = typeof d.reportUrl === "string" ? d.reportUrl : typeof d.publicHash === "string" ? d.publicHash : null;
44
+ const ref = typeof d.id === "string" && report ? ` (verification id: ${d.id}, report: ${report})` : "";
45
+ return new Error(base + ref);
46
+ }
47
+
36
48
  async function apiGet(path, key = API_KEY) {
37
49
  const res = await fetch(`${BASE_URL}${path}`, {
38
50
  headers: { ...authHeader(key) },
@@ -60,8 +72,11 @@ async function uploadProof(milestoneId, filename, content, key = API_KEY) {
60
72
  // Stream verify via SSE — used by the legacy two-step `submit` + `verify --proof <id>` flow
61
73
  async function streamVerify(proofId, key = API_KEY) {
62
74
  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);
75
+ // 235s: one step below the 240s maxDuration of /api/verify, not above its
76
+ // worst case — the tail reaches ~239s, so no value is both. See
77
+ // VERIFY_TAIL_WORST_CASE_MS in src/lib/latency-budget.ts. Cleared after the read loop, not at the fetch
78
+ // (SEC-073), which is what makes the number mean anything.
79
+ const timeout = setTimeout(() => controller.abort(), 235_000);
65
80
 
66
81
  let res;
67
82
  try {
@@ -75,14 +90,35 @@ async function streamVerify(proofId, key = API_KEY) {
75
90
  body: JSON.stringify({ proofId }),
76
91
  signal: controller.signal,
77
92
  });
78
- } finally {
93
+ } catch (e) {
79
94
  clearTimeout(timeout);
95
+ // SEC-073. Now that the deadline is live it can actually fire, and a raw
96
+ // `DOMException: This operation was aborted` names no remedy — worse, per
97
+ // the tail arithmetic in latency-budget.ts the abort can land AFTER
98
+ // releaseMilestone(), so "it failed" may be false about the money.
99
+ if (e && e.name === "AbortError") {
100
+ throw new Error(
101
+ "Verification is still running on the server — this client stopped waiting, it did not fail. " +
102
+ `Poll GET /api/agent/proof-status/${proofId} for the verdict.`
103
+ );
104
+ }
105
+ throw e;
80
106
  }
81
107
 
82
108
  if (!res.ok) {
109
+ // SEC-073, second pass. Replacing the fetch's `finally` with a `catch` left
110
+ // THIS exit uncovered: a 409 from /api/verify (already verified, panel in
111
+ // progress, resubmit limit) returned with a 235 s timer still pending. In a
112
+ // long-lived MCP server that holds the event loop and later aborts a fetch
113
+ // that finished minutes ago.
114
+ clearTimeout(timeout);
83
115
  const data = await res.json().catch(() => ({}));
84
116
  throw new Error(data.error ?? `HTTP ${res.status}`);
85
117
  }
118
+ if (!res.body) {
119
+ clearTimeout(timeout);
120
+ throw new Error("Verification stream had no body");
121
+ }
86
122
 
87
123
  const votes = [];
88
124
  const reader = res.body.getReader();
@@ -117,15 +153,127 @@ async function streamVerify(proofId, key = API_KEY) {
117
153
  }
118
154
  }
119
155
  }
156
+ // SEC-073. Cleared HERE, after the read loop — not in a `finally` around the
157
+ // `fetch`, which resolves on response HEADERS about 12 s into `/api/verify`
158
+ // while the whole 205 s retry ladder arrives afterwards out of `res.body`.
159
+ // At the fetch this was a time-to-first-byte deadline over a twelve-second
160
+ // phase, so its value bounded nothing and could sit below the server's worst
161
+ // case unnoticed. CLIENT_ABORT_MS was raised to 235_000 in the same commit.
162
+ } catch (e) {
163
+ // The deadline can now fire DURING the read — that is the whole point of
164
+ // clearing it below the loop rather than at the fetch. Same translation as
165
+ // the fetch-side catch above.
166
+ if (e && e.name === "AbortError") {
167
+ throw new Error(
168
+ "Verification is still running on the server — this client stopped waiting, it did not fail. " +
169
+ `Poll GET /api/agent/proof-status/${proofId} for the verdict.`
170
+ );
171
+ }
172
+ throw e;
120
173
  } finally {
174
+ clearTimeout(timeout);
121
175
  reader.cancel().catch(() => {});
122
176
  }
123
177
 
124
178
  throw new Error("Stream ended without a complete event");
125
179
  }
126
180
 
181
+ /**
182
+ * One human-readable line for a verification `complete` frame.
183
+ *
184
+ * Every arm says whether FUNDS MOVED, because that is the one thing a caller cannot
185
+ * find out any other way. `action === "VERIFIED"` alone never means they did: a hold and
186
+ * a compliance block carry their own flag, a success arrives as `COMPLETED`, and a BARE
187
+ * `VERIFIED` is reachable only when the automatic release was attempted and FAILED.
188
+ */
189
+ function outcomeLine(r) {
190
+ const c = r.confidence ?? 0;
191
+ if (r.action === "SUPERSEDED") {
192
+ return "ℹ️ SUPERSEDED — another proof on this milestone was decided first, so this verification changed nothing. Your evidence was recorded.";
193
+ }
194
+ if (r.action === "COMPLETED") {
195
+ 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}` : ""}.`;
198
+ }
199
+ if (r.complianceBlocked === true) {
200
+ return `⏸️ VERIFIED (${c}%) — payout on hold pending compliance review. No funds moved.`;
201
+ }
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.`;
204
+ }
205
+ 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.`;
207
+ }
208
+ if (r.timedOut === true) {
209
+ 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.";
210
+ }
211
+ if (r.action === "PENDING_REVIEW") {
212
+ return `⏳ PENDING_REVIEW (${c}%) — manual review required.`;
213
+ }
214
+ if (r.action === "REJECTED") {
215
+ return `❌ REJECTED (${c}%) — resubmit with stronger proof.`;
216
+ }
217
+ 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.`;
218
+ }
219
+
127
220
  // ─── Output helpers ───────────────────────────────────────────────────────────
128
221
 
222
+
223
+ /**
224
+ * One `verify/standalone` call under a client deadline (SEC-073).
225
+ *
226
+ * The route declares `maxDuration = 120`; without this the only signal a hung
227
+ * call gives is the platform tearing the connection down, which reaches the
228
+ * caller as a transport error with nothing in it to act on. One function rather
229
+ * than a copy per shape: the two fetches it wraps are twenty lines apart in the
230
+ * same handler, and a guard reading either in isolation would have reported the
231
+ * pair as protected (F-12/F-60).
232
+ *
233
+ * Mirrors `STANDALONE_CLIENT_ABORT_MS` in `src/lib/latency-budget.ts`. A
234
+ * published package cannot import from the app, so this is a copy by necessity;
235
+ * `standalone-client-deadline.test.ts` is what keeps the FOUR copies equal — this
236
+ * file, the other published package, the SDK, and the owner in
237
+ * `src/lib/latency-budget.ts`.
238
+ */
239
+ const STANDALONE_CLIENT_ABORT_MS = 115_000;
240
+
241
+ async function withStandaloneDeadline(run) {
242
+ const controller = new AbortController();
243
+ const timer = setTimeout(() => controller.abort(), STANDALONE_CLIENT_ABORT_MS);
244
+ try {
245
+ return await run(controller.signal);
246
+ } catch (err) {
247
+ if (err && err.name === "AbortError") {
248
+ throw new Error(
249
+ `Verification did not answer within ${Math.round(STANDALONE_CLIENT_ABORT_MS / 1000)}s. ` +
250
+ "The server may still be working on it — retry, and if it keeps timing out the panel is degraded."
251
+ );
252
+ }
253
+ throw err;
254
+ } finally {
255
+ clearTimeout(timer);
256
+ }
257
+ }
258
+
259
+ /**
260
+ * MACHINE OUTPUT ON stdout, and that is the point of this comment.
261
+ *
262
+ * `SEC-116-INVENTORY` exempted this CLI with the reason "schreibt nach stdout/stderr
263
+ * für einen Menschen". That is true of `err()` below and of the human summaries — and
264
+ * FALSE of this function, which emits `JSON.stringify` for the "CI pipelines and
265
+ * subprocesses" the README names as the target. An exemption whose reason holds for
266
+ * one half of its own subject is F-70, and it is recorded here rather than left in a
267
+ * document nobody reads next to the code.
268
+ *
269
+ * No marker is added, and that is a decision rather than an omission: this CLI's JSON
270
+ * is a published output shape, and prefixing fields inside it would change what every
271
+ * existing parser reads. The honest statement instead — any free text in this output
272
+ * (`reasoning`, `summary`, per-model votes) is MODEL OUTPUT over evidence a
273
+ * counterparty submitted, and reaches you RAW. If you pipe it into a model, treat it
274
+ * as data and not as instructions; the marked surfaces are the two MCP servers and the
275
+ * `/api/agent/verify/*` routes.
276
+ */
129
277
  function print(obj) {
130
278
  console.log(JSON.stringify(obj, null, 2));
131
279
  }
@@ -154,6 +302,8 @@ Commands:
154
302
  join Join a contract as Builder via invite code
155
303
  handoff Send a contract invite to a Builder agent by Agent ID
156
304
  check-invites Check pending contract invites for this agent
305
+ review-queue As funder: list milestones still waiting for your verdict
306
+ review As funder: approve or reject a milestone under manual review
157
307
  me Print this agent's ID
158
308
 
159
309
  Options for each command:
@@ -189,7 +339,7 @@ Options for each command:
189
339
  escrow-fund [REAL on-chain RLUSD — agent's own wallet]
190
340
  --contract <id> required
191
341
  --private-key <key> required EVM private key (funds come from this wallet)
192
- --amount <usd> optional override milestone amount
342
+ --amount <usd> IGNORED the escrowed amount is the milestone's own amountUSD
193
343
  --milestone <id> optional
194
344
 
195
345
  submit [legacy: use verify instead]
@@ -242,6 +392,13 @@ Examples:
242
392
  # (Builder agent runs:)
243
393
  cascrow check-invites
244
394
  cascrow join --invite <code>
395
+
396
+ # As the funder: decide what the AI panel could not (confidence 60-85)
397
+ # Poll this — a manual_review.required webhook you missed is a decision nobody
398
+ # makes, and cascrow auto-approves an undecided milestone after 48h.
399
+ cascrow review-queue
400
+ cascrow review --contract <contractId> --decision APPROVE
401
+ cascrow review --contract <contractId> --decision REJECT --milestone <milestoneId>
245
402
  `);
246
403
  process.exit(0);
247
404
  }
@@ -317,18 +474,46 @@ async function cmdVerifyWork(args) {
317
474
  form.append("taskDescription", args.task);
318
475
  form.append("image", new Blob([imageBuffer], { type: imageMimeType }), args.image.split("/").pop());
319
476
 
320
- const res = await fetch(`${BASE_URL}/api/verify/standalone`, {
321
- method: "POST",
322
- headers: { ...authHeader(API_KEY) },
323
- body: form,
477
+ const { res, data: body } = await withStandaloneDeadline(async (signal) => {
478
+ const r = await fetch(`${BASE_URL}/api/verify/standalone`, {
479
+ method: "POST",
480
+ headers: { ...authHeader(API_KEY) },
481
+ body: form,
482
+ signal,
483
+ });
484
+ // The body is read INSIDE the deadline. A response whose headers arrived
485
+ // and whose body then stalls is otherwise unbounded — a deadline that ends
486
+ // at the headers bounds time-to-first-byte and nothing else (SEC-073).
487
+ return { res: r, data: await r.json() };
324
488
  });
325
- data = await res.json();
326
- if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
489
+ if (!res.ok) throw standaloneFailure(res.status, body);
490
+ // Assigned to the OUTER `data`, not shadowing it: `const { data }` inside the
491
+ // branch left the function-scoped variable undefined and the next line read
492
+ // `data.decision` off it. `node --check` accepts a shadow happily.
493
+ data = body;
327
494
  } else {
328
495
  const body = { taskDescription: args.task };
329
496
  if (args.pr) body.prUrl = args.pr;
330
497
  if (args.code) body.codeText = args.code;
331
- data = await apiPost("/api/verify/standalone", body);
498
+ // Not `apiPost`: its generic throw drops the row reference the 500 carries.
499
+ const { res, data: answer } = await withStandaloneDeadline(async (signal) => {
500
+ const r = await fetch(`${BASE_URL}/api/verify/standalone`, {
501
+ method: "POST",
502
+ headers: { ...authHeader(API_KEY), "Content-Type": "application/json" },
503
+ body: JSON.stringify(body),
504
+ signal,
505
+ });
506
+ // The body is read INSIDE the deadline. A response whose headers arrived
507
+ // and whose body then stalls is otherwise unbounded — a deadline that ends
508
+ // at the headers bounds time-to-first-byte and nothing else (SEC-073).
509
+ return { res: r, data: await r.json() };
510
+ });
511
+ if (!res.ok) throw standaloneFailure(res.status, answer);
512
+ // Assigned to the OUTER `data`, not shadowing it: `const { data }` inside the
513
+ // branch left the function-scoped variable undefined and the next line read
514
+ // `data.decision` off it. `answer` rather than `body` because this branch
515
+ // already has a `body` — the REQUEST payload — twelve lines up.
516
+ data = answer;
332
517
  }
333
518
 
334
519
  const votes = Array.isArray(data.modelVotes) ? data.modelVotes : [];
@@ -359,25 +544,48 @@ async function cmdVerifyWork(args) {
359
544
  // Auto-releases funds and mints NFT on approval — no separate `verify --proof <id>` needed.
360
545
  async function cmdVerify(args) {
361
546
  if (!API_KEY) err("CASCROW_API_KEY is not set");
362
- if (!args.contract) err("--contract is required");
363
547
  if (!args.proof) err("--proof is required");
364
548
 
365
- // Legacy mode: --proof looks like a proof ID (prf_...) and no --contract — use stream verify
549
+ // Legacy mode: --proof looks like a proof ID (prf_...) and no --contract — use stream verify.
550
+ // Checked BEFORE the --contract requirement below: that check used to run
551
+ // first and process.exit(1) whenever --contract was missing, which made
552
+ // this branch (and streamVerify()) permanently unreachable — exactly the
553
+ // invocation cmdSubmit's own output tells users to run.
366
554
  if (!args.contract && args.proof && !args.proof.includes(" ")) {
367
555
  process.stderr.write("\nRunning AI verification (5 models)...\n\n");
368
556
  const result = await streamVerify(args.proof);
369
- const passed = result.decision === "YES" && (result.confidence ?? 0) >= 85;
557
+ // SEC-055/058/059 at the third published surface. `passed` used to be
558
+ // `decision === "YES" && confidence >= 85`, i.e. a re-implementation of the
559
+ // three-tier rule from the PROOF's verdict — which answers a different question
560
+ // from the one a caller is asking. `COMPLETED`, `SUPERSEDED`, a compliance block
561
+ // and a release hold all rendered as verified, and a panel outage
562
+ // (`decision: "PENDING"`) rendered as "NOT VERIFIED 0%", i.e. our own outage read
563
+ // back to the builder as a rejection.
564
+ //
565
+ // `passed` now means MONEY MOVED, and only `COMPLETED` says that.
566
+ const released = result.action === "COMPLETED";
567
+ const passed = released;
370
568
  print({
371
569
  decision: result.decision,
372
570
  confidence: result.confidence,
373
571
  passed,
374
572
  action: result.action,
573
+ released,
574
+ alreadyReleased: result.alreadyReleased === true,
575
+ superseded: result.action === "SUPERSEDED",
576
+ complianceBlocked: result.complianceBlocked === true,
577
+ releaseHoldUntil: result.releaseHoldUntil ?? null,
578
+ panelUnavailable: result.timedOut === true,
579
+ txHash: result.txHash ?? null,
375
580
  reasoning: result.reasoning,
376
581
  modelVotes: (result.votes ?? []).map(v => ({ model: v.model, decision: v.decision, confidence: v.confidence })),
377
582
  });
583
+ process.stderr.write(outcomeLine(result) + "\n");
378
584
  return;
379
585
  }
380
586
 
587
+ if (!args.contract) err("--contract is required");
588
+
381
589
  // One-shot mode: text proof → /api/mcp/submit → verdict + auto-release
382
590
  const links = args.links
383
591
  ? args.links.split(",").map(l => l.trim()).filter(Boolean)
@@ -395,8 +603,18 @@ async function cmdVerify(args) {
395
603
  evidence,
396
604
  });
397
605
 
398
- const verdict = data.verdict ?? "unknown";
606
+ // `decided: "elsewhere"` and `panelUnavailable` are 200 responses that carry NO
607
+ // `verdict` key, so `data.verdict ?? "unknown"` fell to the final arm below and told
608
+ // the builder "❌ REJECTED — resubmit with stronger proof" for a milestone that may
609
+ // well have been approved and paid (SEC-055). Classified before the verdict is read.
610
+ const verdict = data.decided === "elsewhere"
611
+ ? "decided_elsewhere"
612
+ : data.panelUnavailable
613
+ ? "panel_unavailable"
614
+ : (data.verdict ?? "unknown");
399
615
  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;
400
618
 
401
619
  // Print model votes if available
402
620
  const votes = Array.isArray(data.model_votes) ? data.model_votes : [];
@@ -407,20 +625,43 @@ async function cmdVerify(args) {
407
625
  process.stderr.write("\n");
408
626
 
409
627
  let statusLine;
410
- if (verdict === "approved") {
628
+ if (verdict === "decided_elsewhere") {
629
+ statusLine =
630
+ "ℹ️ This milestone was decided by another request while your proof was being verified — " +
631
+ "by the funder's review, or by a concurrent submission. Your evidence was recorded. " +
632
+ "Check the milestone status before resubmitting";
633
+ } else if (verdict === "panel_unavailable") {
634
+ statusLine =
635
+ "⏳ PENDING_REVIEW — the verification panel could not reach a verdict (too few models " +
636
+ "answered). This is NOT a rejection and NOT a judgement of your work; a human decides, " +
637
+ "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
+ } else if (verdict === "approved") {
411
643
  statusLine = `✅ VERIFIED (${confidence}% confidence)`;
412
644
  if (data.on_chain_url) statusLine += `\n Proof: ${data.on_chain_url}`;
413
645
  } else if (verdict === "pending_review") {
414
646
  statusLine = `⏳ PENDING_REVIEW (${confidence}% confidence) — manual review triggered`;
415
- } else {
647
+ } else if (verdict === "rejected") {
416
648
  statusLine = `❌ REJECTED (${confidence}% confidence) — resubmit with stronger proof`;
649
+ } else {
650
+ // Not a rejection: an outcome this version does not know. Drawing no conclusion is
651
+ // the one answer that cannot be wrong, and it makes the next value visible instead
652
+ // of silently a rejection.
653
+ statusLine =
654
+ `⚠️ This submission finished with an outcome this CLI does not recognise (verdict: ${verdict}). ` +
655
+ "No conclusion was drawn — check the milestone status and upgrade cascrow-cli";
417
656
  }
418
657
  process.stderr.write(statusLine + "\n\n");
419
658
 
420
659
  print({
421
660
  verdict,
422
661
  confidence,
423
- passed: verdict === "approved",
662
+ // MONEY MOVED — not "the verdict was approved". A scheduled payout has not happened.
663
+ passed: verdict === "approved" && !releaseHoldUntil,
664
+ release_hold_until: releaseHoldUntil,
424
665
  reasoning: data.reasoning ?? "",
425
666
  on_chain_url: data.on_chain_url ?? null,
426
667
  proof_id: data.proof_id ?? null,
@@ -474,7 +715,14 @@ async function cmdEscrowFund(args) {
474
715
  agentPrivateKey: args["private-key"],
475
716
  };
476
717
  if (args.milestone) body.milestoneId = args.milestone;
477
- if (args.amount) body.amountUSD = parseFloat(args.amount);
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
+ }
478
726
 
479
727
  process.stderr.write("\nFunding milestone on-chain (approve + fundMilestone)...\n");
480
728
  const data = await apiPost("/api/agent/escrow-fund", body);
@@ -540,6 +788,62 @@ async function cmdCheckInvites() {
540
788
  }
541
789
  }
542
790
 
791
+ /**
792
+ * The funder's pending-verdict queue, and the verdict itself.
793
+ *
794
+ * Both go through the routes every other surface uses — `GET
795
+ * /api/agent/review-queue` and `POST /api/contracts/review` — which ask the same
796
+ * owner module, so the queue this prints and the queue the door accepts a
797
+ * verdict on cannot come apart (F-22).
798
+ *
799
+ * `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
803
+ * its reader cannot act on names a remedy they are unable to perform (F-72).
804
+ */
805
+ async function cmdReviewQueue() {
806
+ if (!API_KEY) err("CASCROW_API_KEY is not set");
807
+ const data = await apiGet("/api/agent/review-queue");
808
+ const contracts = data.contracts ?? [];
809
+ print(data);
810
+ if (contracts.length === 0) {
811
+ process.stderr.write("\nNo milestones are waiting for your verdict.\n\n");
812
+ return;
813
+ }
814
+ const lines = contracts.map((c) => {
815
+ const pending = c.pendingMilestones ?? [];
816
+ const ms =
817
+ pending.length === 1
818
+ ? `milestone ${pending[0].milestoneId}`
819
+ : pending.length > 1
820
+ ? `${pending.length} milestones pending — pass --milestone <id>`
821
+ : "contract-level review — omit --milestone";
822
+ return ` ${c.contractId} ${c.amountUSD} RLUSD (${ms})`;
823
+ }).join("\n");
824
+ const more = data.truncated ? `\n(${data.total} in total — this list is capped.)` : "";
825
+ process.stderr.write(
826
+ `\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"
828
+ );
829
+ }
830
+
831
+ async function cmdReview(args) {
832
+ if (!API_KEY) err("CASCROW_API_KEY is not set");
833
+ const contractId = args.contract;
834
+ const decision = (args.decision ?? "").toUpperCase();
835
+ if (!contractId) err("--contract <id> is required (see: cascrow review-queue)");
836
+ if (decision !== "APPROVE" && decision !== "REJECT") err("--decision must be APPROVE or REJECT");
837
+ const body = args.milestone ? { contractId, decision, milestoneId: args.milestone } : { contractId, decision };
838
+ const data = await apiPost("/api/contracts/review", body);
839
+ print(data);
840
+ const what =
841
+ decision === "APPROVE"
842
+ ? "approved — the escrow is being released to the builder"
843
+ : "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`);
845
+ }
846
+
543
847
  async function cmdMe() {
544
848
  if (!API_KEY) err("CASCROW_API_KEY is not set");
545
849
  const data = await apiGet("/api/agent/me");
@@ -569,6 +873,8 @@ try {
569
873
  case "join": await cmdJoin(args); break;
570
874
  case "handoff": await cmdHandoff(args); break;
571
875
  case "check-invites": await cmdCheckInvites(); break;
876
+ case "review-queue": await cmdReviewQueue(); break;
877
+ case "review": await cmdReview(args); break;
572
878
  case "me": await cmdMe(); break;
573
879
  default:
574
880
  console.error(`Unknown command: ${command}\n`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cascrow-cli",
3
- "version": "1.2.2",
4
- "description": "CLI for Cascrow — agentic escrow and verification on the XRP Ledger",
3
+ "version": "1.3.0",
4
+ "description": "CLI for Cascrow — trust infrastructure for the agent economy: AI verification, on-chain reputation, and milestone escrow on the XRP Ledger",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "bin": {
@@ -20,7 +20,10 @@
20
20
  "milestone",
21
21
  "agent",
22
22
  "agentic",
23
- "verification"
23
+ "verification",
24
+ "reputation",
25
+ "trust",
26
+ "agent-economy"
24
27
  ],
25
28
  "author": "Cascrow <dev@cascrow.com>",
26
29
  "license": "MIT",