cascrow-cli 1.3.0 → 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/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## 1.4.0 — 2026-09-25
4
+
5
+ - New `cascrow status --proof <id> [--wait]` — show, or wait for, a proof's verification result. `submit` now points to it: verification starts by itself after an upload.
6
+ - `cascrow verify --proof <id>` on a proof whose verification is already running waits for that run instead of failing.
7
+ - `cascrow create` takes `--criteria`, `--criteria-file`, `--checklist-file` and `--review-mode` — the bar the AI panel judges against, kept out of the title (which is printed on the certificate).
8
+ - Every API call has a deadline and survives a non-JSON answer; errors end with the API's stable code in brackets, e.g. `[RATE_LIMITED]`.
9
+ - `status` never reports a released milestone as closed.
package/README.md CHANGED
@@ -53,7 +53,8 @@ is never anchored.
53
53
  | `create` | Create a milestone contract | Yes |
54
54
  | `fund` | Activate a milestone (simulation — no on-chain tx) | Yes |
55
55
  | `escrow-fund` | Fund a milestone with real on-chain RLUSD escrow | Yes |
56
- | `submit` | Upload proof text (returns `proofId` for the legacy verify flow) | Yes |
56
+ | `submit` | Upload proof text — verification starts by itself; returns `proofId` | Yes |
57
+ | `status` | Show, or `--wait` for, the verification result of a proof | Yes |
57
58
  | `get` | Get contract status and milestones | Yes |
58
59
  | `join` | Join a contract as Builder via invite code | Yes |
59
60
  | `handoff` | Send a contract invite to a Builder agent by Agent ID | Yes |
@@ -97,11 +98,18 @@ Exactly one of `--pr`, `--code`, or `--image` is required alongside `--task`.
97
98
  ### `create`
98
99
 
99
100
  ```
100
- --title <title> required milestone title + acceptance criteria
101
+ --title <title> required short milestone title (printed on the certificate)
101
102
  --days <n> optional deadline days from now (default: 7)
102
103
  --amount <usd> optional RLUSD to lock in escrow (default: 0 = verification-only)
104
+ --criteria <text> optional what the AI panel checks the proof against (max 4000 chars)
105
+ --criteria-file <path> optional the same, read from a file
106
+ --checklist-file <path> optional JSON array of { title, severity?, id? } (max 20), scored item by item on text proofs
107
+ --review-mode <mode> optional AUTO | MANUAL | MANUAL_AUTO — what happens when the panel is unsure (60–85%)
103
108
  ```
104
109
 
110
+ Keep acceptance criteria out of `--title`: the title is printed on the public
111
+ certificate, and the panel judges the proof against `--criteria`.
112
+
105
113
  ### `fund`
106
114
 
107
115
  ```
@@ -118,7 +126,7 @@ Exactly one of `--pr`, `--code`, or `--image` is required alongside `--task`.
118
126
  --milestone <id> optional
119
127
  ```
120
128
 
121
- ### `submit` [legacy — use `verify` instead]
129
+ ### `submit` [or use `verify` for text proof in one call]
122
130
 
123
131
  ```
124
132
  --milestone <id> required
@@ -126,6 +134,19 @@ Exactly one of `--pr`, `--code`, or `--image` is required alongside `--task`.
126
134
  --file <name> optional filename (default: proof-report.txt)
127
135
  ```
128
136
 
137
+ Verification starts by itself after the upload — wait for it with `status`, don't
138
+ start it again with `verify --proof` (that would only wait for the same run).
139
+
140
+ ### `status`
141
+
142
+ ```
143
+ --proof <id> required
144
+ --wait optional wait up to ~2 min; stops early on 401/403/404 or an interrupted run
145
+ ```
146
+
147
+ Errors end with the API's stable code in brackets, e.g. `[RATE_LIMITED]` — branch
148
+ on that, not on the wording.
149
+
129
150
  ### `get`
130
151
 
131
152
  ```
package/builder-agent.js CHANGED
@@ -15,24 +15,57 @@ if (!API_KEY) {
15
15
  process.exit(1);
16
16
  }
17
17
 
18
- async function apiPost(path, body) {
19
- const res = await fetch(`${BASE_URL}${path}`, {
20
- method: "POST",
21
- headers: { "Authorization": `Bearer ${API_KEY}`, "Content-Type": "application/json" },
22
- body: JSON.stringify(body),
23
- });
24
- const data = await res.json();
25
- if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
18
+ // Every call reads the body as text and parses it in a try (an HTML 502 used to
19
+ // crash the agent as "Unexpected token '<'"), carries a deadline over the body
20
+ // read, and throws an Error with `status` and `code` (T11-A4). Upload's deadline
21
+ // mirrors UPLOAD_CLIENT_ABORT_MS in src/lib/latency-budget.ts.
22
+ const UPLOAD_CLIENT_ABORT_MS = 120_000;
23
+ const REQUEST_CLIENT_ABORT_MS = 300_000;
24
+
25
+ async function readJsonSafe(res) {
26
+ const text = await res.text();
27
+ if (!text) return {};
28
+ try {
29
+ return JSON.parse(text);
30
+ } catch {
31
+ return { error: `HTTP ${res.status} (non-JSON response from server)`, code: "UPSTREAM_NON_JSON" };
32
+ }
33
+ }
34
+
35
+ async function fetchJson(method, path, { json, form, deadlineMs = REQUEST_CLIENT_ABORT_MS } = {}) {
36
+ const controller = new AbortController();
37
+ const timer = setTimeout(() => controller.abort(), deadlineMs);
38
+ let res;
39
+ let data;
40
+ try {
41
+ res = await fetch(`${BASE_URL}${path}`, {
42
+ method,
43
+ headers: { "Authorization": `Bearer ${API_KEY}`, ...(json !== undefined ? { "Content-Type": "application/json" } : {}) },
44
+ ...(json !== undefined ? { body: JSON.stringify(json) } : form !== undefined ? { body: form } : {}),
45
+ signal: controller.signal,
46
+ });
47
+ data = await readJsonSafe(res);
48
+ } catch (e) {
49
+ if (e && e.name === "AbortError") {
50
+ throw Object.assign(new Error(`No answer from ${path} within ${Math.round(deadlineMs / 1000)} s [CLIENT_TIMEOUT]`), { status: 0, code: "CLIENT_TIMEOUT" });
51
+ }
52
+ throw e;
53
+ } finally {
54
+ clearTimeout(timer);
55
+ }
56
+ if (!res.ok || data.code === "UPSTREAM_NON_JSON") {
57
+ const code = typeof data.code === "string" ? data.code : null;
58
+ throw Object.assign(new Error(`${data.error ?? `HTTP ${res.status}`}${code ? ` [${code}]` : ""}`), { status: res.status, code });
59
+ }
26
60
  return data;
27
61
  }
28
62
 
63
+ async function apiPost(path, body) {
64
+ return fetchJson("POST", path, { json: body });
65
+ }
66
+
29
67
  async function apiGet(path) {
30
- const res = await fetch(`${BASE_URL}${path}`, {
31
- headers: { "Authorization": `Bearer ${API_KEY}` },
32
- });
33
- const data = await res.json();
34
- if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
35
- return data;
68
+ return fetchJson("GET", path);
36
69
  }
37
70
 
38
71
  async function uploadProof(milestoneId, content) {
@@ -40,14 +73,7 @@ async function uploadProof(milestoneId, content) {
40
73
  const form = new FormData();
41
74
  form.append("file", blob, "proof-report.txt");
42
75
  form.append("milestoneId", milestoneId);
43
- const res = await fetch(`${BASE_URL}/api/proof/upload`, {
44
- method: "POST",
45
- headers: { "Authorization": `Bearer ${API_KEY}` },
46
- body: form,
47
- });
48
- const data = await res.json();
49
- if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
50
- return data;
76
+ return fetchJson("POST", "/api/proof/upload", { form, deadlineMs: UPLOAD_CLIENT_ABORT_MS });
51
77
  }
52
78
 
53
79
  // SEC-073. The FIFTH client door of `/api/verify`, and until 2026-09-22 the only
@@ -93,8 +119,16 @@ async function streamVerify(proofId) {
93
119
  }
94
120
  if (!res.ok) {
95
121
  clearTimeout(timeout);
96
- const data = await res.json().catch(() => ({}));
97
- throw new Error(data.error ?? `HTTP ${res.status}`);
122
+ const data = await readJsonSafe(res);
123
+ // T01-8. Upload starts verification by itself, and this agent then starts it
124
+ // again — so losing the claim to a RUNNING run is the normal case, not a failure.
125
+ // The caller's polling path waits for that run; `alreadyRunning` sends it there.
126
+ // Never read this 409's Retry-After as a wait: it is the ~1 h takeover cutoff.
127
+ if (res.status === 409 && data.code === "VERIFICATION_IN_PROGRESS" && data.phase === "running") {
128
+ throw Object.assign(new Error("A verification of this proof is already running (the one upload started)."), { alreadyRunning: true });
129
+ }
130
+ const code = typeof data.code === "string" ? data.code : null;
131
+ throw Object.assign(new Error(`${data.error ?? `HTTP ${res.status}`}${code ? ` [${code}]` : ""}`), { status: res.status, code });
98
132
  }
99
133
  if (!res.body) {
100
134
  clearTimeout(timeout);
@@ -224,7 +258,15 @@ VERIFICATION CRITERIA MET:
224
258
  // Stream disconnected before complete event (e.g. Vercel 150s timeout).
225
259
  // The verification is still running server-side — just poll for the result,
226
260
  // do NOT call streamVerify again (would trigger duplicate AI verification).
227
- console.log(` ⚠️ Stream ended early, polling for result...`);
261
+ // Two ways here, and the loop below serves both: the stream dropped mid-run, or
262
+ // it never opened because upload's own run already held the claim (T01-8).
263
+ if (streamErr && streamErr.alreadyRunning) {
264
+ console.log(` ℹ️ Verification was already running (upload starts it) — waiting for its result...`);
265
+ } else if (streamErr && (streamErr.status === 401 || streamErr.status === 403 || streamErr.status === 404)) {
266
+ throw streamErr;
267
+ } else {
268
+ console.log(` ⚠️ Stream ended early, polling for result...`);
269
+ }
228
270
  // SEC-076. This polled `contract.status`, and that column answers a different
229
271
  // question than the one asked here. It is a single slot with nine writers:
230
272
  // funding a SIBLING milestone writes `contractStatusAfterFunding(...)` over
@@ -240,13 +282,33 @@ VERIFICATION CRITERIA MET:
240
282
  // funded a specific one, so the honest read is that row. The contract-level
241
283
  // read is kept only for the legacy population that has no milestone rows at
242
284
  // all (proofs written via the contractId-only path).
243
- const TERMINAL_PROOF_STATES = ["VERIFIED", "COMPLETED", "REJECTED", "PENDING_REVIEW"];
285
+ //
286
+ // EXPIRED is terminal too. Since 2026-09-23 `/api/verify` no longer reopens a
287
+ // milestone that went EXPIRED while its panel ran, so without it this loop polled a
288
+ // closed row for five minutes and then threw "Timed out" — and it must never be
289
+ // mapped to a YES: the milestone moved no funds through this verification.
290
+ const TERMINAL_PROOF_STATES = ["VERIFIED", "COMPLETED", "REJECTED", "PENDING_REVIEW", "EXPIRED"];
244
291
  for (let i = 0; i < 60; i++) {
245
292
  await new Promise((r) => setTimeout(r, 5000));
246
- const contract = await apiGet(`/api/contracts/${joined.contractId}`);
293
+ let contract;
294
+ try {
295
+ contract = await apiGet(`/api/contracts/${joined.contractId}`);
296
+ } catch (e) {
297
+ // 401/403/404 do not change by waiting; anything else is transient. One
298
+ // dropped poll used to end the whole run.
299
+ if (e && (e.status === 401 || e.status === 403 || e.status === 404)) throw e;
300
+ continue;
301
+ }
247
302
  const mine = (contract.milestones ?? []).find((m) => m.id === fundedMilestoneId);
248
303
  const observed = mine ? mine.status : (contract.milestones ?? []).length === 0 ? contract.status : null;
249
304
  if (observed && TERMINAL_PROOF_STATES.includes(observed)) {
305
+ if (observed === "EXPIRED") {
306
+ // No verdict is read on this path: the stream that carried it dropped, and
307
+ // the row says only that it is closed.
308
+ result = { decision: null, confidence: 0, action: "EXPIRED", milestoneClosed: true };
309
+ console.log(` ✓ Got result via polling: ${observed}`);
310
+ break;
311
+ }
250
312
  const decision = ["VERIFIED", "COMPLETED"].includes(observed) ? "YES" : "NO";
251
313
  result = { decision, confidence: 0, action: observed };
252
314
  console.log(` ✓ Got result via polling: ${observed}`);
@@ -269,8 +331,20 @@ VERIFICATION CRITERIA MET:
269
331
  const released = result.action === "COMPLETED";
270
332
  const passed = released;
271
333
  console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
272
- if (result.action === "SUPERSEDED") {
273
- console.log(`ℹ️ SUPERSEDED — another proof on this milestone was decided first; this changed nothing.`);
334
+ if (result.milestoneClosed === true && result.action === "EXPIRED") {
335
+ // `GET /api/contracts/{id}` does not return the milestone's `terminalReason`, so this
336
+ // cannot say whether the funder was refunded or the builder was paid on-chain, and
337
+ // does not guess.
338
+ console.log(
339
+ `⛔ CLOSED — the milestone was closed (EXPIRED) while the panel ran or after it decided, so this verification moved no funds. ` +
340
+ `The reason is not available here — check the contract page before assuming where the funds are. Do not resubmit to it.`
341
+ );
342
+ } else if (result.action === "SUPERSEDED") {
343
+ console.log(
344
+ result.settledAs
345
+ ? `ℹ️ SUPERSEDED — this milestone was closed (${result.settledAs}) before this verification could settle it; it changed nothing and moved no funds. Do not resubmit to it.`
346
+ : `ℹ️ SUPERSEDED — another proof on this milestone was decided first; this changed nothing.`
347
+ );
274
348
  } else if (released) {
275
349
  console.log(
276
350
  result.alreadyReleased === true
@@ -292,7 +366,7 @@ VERIFICATION CRITERIA MET:
292
366
  } else {
293
367
  console.log(`⚠️ Outcome not recognised by this version (action: ${result.action}). No conclusion drawn.`);
294
368
  }
295
- console.log(` Decision: ${result.decision}`);
369
+ console.log(` Decision: ${result.decision ?? "none read (the stream dropped before it)"}`);
296
370
  console.log(` View contract: ${BASE_URL}/contract/${invite.contractId}`);
297
371
  console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
298
372
  }
package/index.js CHANGED
@@ -19,20 +19,86 @@ 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
+
36
102
  // SEC-078. A failed standalone verification still has a row, and the route answers
37
103
  // the 500 with `id`, `publicHash` and `reportUrl` so the caller who paid for it can name it. The
38
104
  // CLI only prints a string, so the reference goes into the string. Both request
@@ -46,12 +112,7 @@ function standaloneFailure(status, data) {
46
112
  }
47
113
 
48
114
  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;
115
+ return fetchJson("GET", path, { key });
55
116
  }
56
117
 
57
118
  async function uploadProof(milestoneId, filename, content, key = API_KEY) {
@@ -59,14 +120,7 @@ async function uploadProof(milestoneId, filename, content, key = API_KEY) {
59
120
  const form = new FormData();
60
121
  form.append("file", blob, filename);
61
122
  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;
123
+ return fetchJson("POST", "/api/proof/upload", { form, key, deadlineMs: UPLOAD_CLIENT_ABORT_MS });
70
124
  }
71
125
 
72
126
  // Stream verify via SSE — used by the legacy two-step `submit` + `verify --proof <id>` flow
@@ -112,8 +166,21 @@ async function streamVerify(proofId, key = API_KEY) {
112
166
  // long-lived MCP server that holds the event loop and later aborts a fetch
113
167
  // that finished minutes ago.
114
168
  clearTimeout(timeout);
115
- const data = await res.json().catch(() => ({}));
116
- throw new Error(data.error ?? `HTTP ${res.status}`);
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
+ });
117
184
  }
118
185
  if (!res.body) {
119
186
  clearTimeout(timeout);
@@ -178,6 +245,73 @@ async function streamVerify(proofId, key = API_KEY) {
178
245
  throw new Error("Stream ended without a complete event");
179
246
  }
180
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
+
181
315
  /**
182
316
  * One human-readable line for a verification `complete` frame.
183
317
  *
@@ -189,7 +323,11 @@ async function streamVerify(proofId, key = API_KEY) {
189
323
  function outcomeLine(r) {
190
324
  const c = r.confidence ?? 0;
191
325
  if (r.action === "SUPERSEDED") {
192
- return "ℹ️ SUPERSEDED — another proof on this milestone was decided first, so this verification changed nothing. Your evidence was recorded.";
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.";
193
331
  }
194
332
  if (r.action === "COMPLETED") {
195
333
  return r.alreadyReleased === true
@@ -297,7 +435,8 @@ Commands:
297
435
  create Create a milestone contract
298
436
  fund Activate a milestone (simulation — no on-chain tx)
299
437
  escrow-fund Fund a milestone with real on-chain RLUSD escrow
300
- 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
301
440
  get Get contract status and milestones
302
441
  join Join a contract as Builder via invite code
303
442
  handoff Send a contract invite to a Builder agent by Agent ID
@@ -328,9 +467,13 @@ Options for each command:
328
467
  --commit <sha> optional Git commit SHA or PR URL
329
468
 
330
469
  create
331
- --title <title> required milestone title + acceptance criteria
470
+ --title <title> required short milestone title (printed on the certificate)
332
471
  --days <n> optional deadline days from now (default: 7)
333
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%)
334
477
 
335
478
  fund
336
479
  --contract <id> required
@@ -342,11 +485,15 @@ Options for each command:
342
485
  --amount <usd> IGNORED the escrowed amount is the milestone's own amountUSD
343
486
  --milestone <id> optional
344
487
 
345
- submit [legacy: use verify instead]
488
+ submit [or use verify for text proof in one call]
346
489
  --milestone <id> required
347
490
  --proof <text> required
348
491
  --file <name> optional filename (default: proof-report.txt)
349
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
+
350
497
  get
351
498
  --contract <id> required
352
499
 
@@ -376,7 +523,7 @@ Examples:
376
523
  cascrow register --email bot@example.com --password secret123
377
524
 
378
525
  # One-shot: create contract, fund it, then verify a code fix
379
- 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
380
527
  cascrow fund --contract <contractId>
381
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
382
529
 
@@ -403,6 +550,65 @@ Examples:
403
550
  process.exit(0);
404
551
  }
405
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
+
406
612
  // ─── Argument parser ──────────────────────────────────────────────────────────
407
613
 
408
614
  function parseArgs(argv) {
@@ -439,8 +645,8 @@ async function cmdRegister(args) {
439
645
  keyName: args["key-name"] ?? "cli",
440
646
  }),
441
647
  });
442
- const data = await res.json();
443
- 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}]` : ""}`);
444
650
  print(data);
445
651
  console.error("\nSave your API key and set it:\n export CASCROW_API_KEY=" + data.apiKey + "\n");
446
652
  }
@@ -484,7 +690,7 @@ async function cmdVerifyWork(args) {
484
690
  // The body is read INSIDE the deadline. A response whose headers arrived
485
691
  // and whose body then stalls is otherwise unbounded — a deadline that ends
486
692
  // at the headers bounds time-to-first-byte and nothing else (SEC-073).
487
- return { res: r, data: await r.json() };
693
+ return { res: r, data: await readJsonSafe(r) };
488
694
  });
489
695
  if (!res.ok) throw standaloneFailure(res.status, body);
490
696
  // Assigned to the OUTER `data`, not shadowing it: `const { data }` inside the
@@ -506,7 +712,7 @@ async function cmdVerifyWork(args) {
506
712
  // The body is read INSIDE the deadline. A response whose headers arrived
507
713
  // and whose body then stalls is otherwise unbounded — a deadline that ends
508
714
  // at the headers bounds time-to-first-byte and nothing else (SEC-073).
509
- return { res: r, data: await r.json() };
715
+ return { res: r, data: await readJsonSafe(r) };
510
716
  });
511
717
  if (!res.ok) throw standaloneFailure(res.status, answer);
512
718
  // Assigned to the OUTER `data`, not shadowing it: `const { data }` inside the
@@ -554,6 +760,13 @@ async function cmdVerify(args) {
554
760
  if (!args.contract && args.proof && !args.proof.includes(" ")) {
555
761
  process.stderr.write("\nRunning AI verification (5 models)...\n\n");
556
762
  const result = await streamVerify(args.proof);
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
+ }
557
770
  // SEC-055/058/059 at the third published surface. `passed` used to be
558
771
  // `decision === "YES" && confidence >= 85`, i.e. a re-implementation of the
559
772
  // three-tier rule from the PROOF's verdict — which answers a different question
@@ -573,6 +786,11 @@ async function cmdVerify(args) {
573
786
  released,
574
787
  alreadyReleased: result.alreadyReleased === true,
575
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,
576
794
  complianceBlocked: result.complianceBlocked === true,
577
795
  releaseHoldUntil: result.releaseHoldUntil ?? null,
578
796
  panelUnavailable: result.timedOut === true,
@@ -601,7 +819,7 @@ async function cmdVerify(args) {
601
819
  contract_id: args.contract,
602
820
  milestone_id: args.milestone ?? undefined,
603
821
  evidence,
604
- });
822
+ }, API_KEY, MCP_SUBMIT_CLIENT_ABORT_MS);
605
823
 
606
824
  // `decided: "elsewhere"` and `panelUnavailable` are 200 responses that carry NO
607
825
  // `verdict` key, so `data.verdict ?? "unknown"` fell to the final arm below and told
@@ -624,8 +842,17 @@ async function cmdVerify(args) {
624
842
  }
625
843
  process.stderr.write("\n");
626
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
+
627
850
  let statusLine;
628
- if (verdict === "decided_elsewhere") {
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") {
629
856
  statusLine =
630
857
  "ℹ️ This milestone was decided by another request while your proof was being verified — " +
631
858
  "by the funder's review, or by a concurrent submission. Your evidence was recorded. " +
@@ -662,6 +889,7 @@ async function cmdVerify(args) {
662
889
  // MONEY MOVED — not "the verdict was approved". A scheduled payout has not happened.
663
890
  passed: verdict === "approved" && !releaseHoldUntil,
664
891
  release_hold_until: releaseHoldUntil,
892
+ ...(settledAs ? { settled_as: settledAs } : {}),
665
893
  reasoning: data.reasoning ?? "",
666
894
  on_chain_url: data.on_chain_url ?? null,
667
895
  proof_id: data.proof_id ?? null,
@@ -679,12 +907,40 @@ async function cmdCreate(args) {
679
907
  deadline.setDate(deadline.getDate() + deadlineDays);
680
908
  const amountUSD = parseFloat(args.amount ?? "0");
681
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
+
682
935
  const data = await apiPost("/api/contracts", {
683
936
  milestones: [{
684
937
  title: args.title,
685
938
  amountUSD,
686
939
  cancelAfter: deadline.toISOString(),
940
+ ...(criteria !== undefined ? { verificationCriteria: criteria } : {}),
941
+ ...(checklist !== undefined ? { verificationChecklist: checklist } : {}),
687
942
  }],
943
+ ...(reviewMode !== undefined ? { agentReviewMode: reviewMode } : {}),
688
944
  });
689
945
 
690
946
  const contractUrl = `${BASE_URL}/contract/${data.contractId}`;
@@ -742,7 +998,25 @@ async function cmdSubmit(args) {
742
998
  args.proof
743
999
  );
744
1000
  print(data);
745
- 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
+ );
746
1020
  }
747
1021
 
748
1022
  async function cmdGet(args) {
@@ -869,6 +1143,7 @@ try {
869
1143
  case "fund": await cmdFund(args); break;
870
1144
  case "escrow-fund": await cmdEscrowFund(args); break;
871
1145
  case "submit": await cmdSubmit(args); break;
1146
+ case "status": await cmdStatus(args); break;
872
1147
  case "get": await cmdGet(args); break;
873
1148
  case "join": await cmdJoin(args); break;
874
1149
  case "handoff": await cmdHandoff(args); break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cascrow-cli",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
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",