cascrow-mcp 1.7.0 → 1.8.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 +10 -0
- package/README.md +6 -6
- package/index.js +534 -107
- package/package.json +1 -1
- package/server.json +2 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.8.0 — 2026-09-25
|
|
4
|
+
|
|
5
|
+
- **Verification starts by itself after `cascrow_submit_proof`.** Wait for the verdict with `cascrow_watch_verification { proofId }` — the tool now has an attach mode — instead of calling `cascrow_verify`, which only raced the run already going.
|
|
6
|
+
- `cascrow_verify` on a proof whose verification is already running waits for that run and returns its result, instead of failing with a 409.
|
|
7
|
+
- `cascrow_create_contract` takes `verificationCriteria` and `verificationChecklist` per milestone (the bar the AI panel judges against — keep it out of the title, which is printed on the certificate) and `agentReviewMode`. A bad checklist item is refused locally with its index.
|
|
8
|
+
- Every API call has a deadline and survives a non-JSON answer: a platform error page reads `HTTP 502 (non-JSON response from server) [UPSTREAM_NON_JSON]` instead of `Unexpected token '<'`. Errors carry the API's stable code and the server's Retry-After.
|
|
9
|
+
- Waiting stops early — and says why — when it cannot help: 401 / 403, a deleted proof (404), an interrupted run. 429 / 503 wait at least Retry-After.
|
|
10
|
+
- The server reports its real version (it said 1.6.2).
|
package/README.md
CHANGED
|
@@ -121,11 +121,11 @@ Rate limit: 3 registrations per IP per hour.
|
|
|
121
121
|
|
|
122
122
|
| Tool | Description |
|
|
123
123
|
|------|-------------|
|
|
124
|
-
| `cascrow_create_contract` | Create a milestone contract. Returns `contractId` + `inviteCode`. |
|
|
125
|
-
| `cascrow_submit_proof` | Submit proof text —
|
|
126
|
-
| `cascrow_verify` |
|
|
124
|
+
| `cascrow_create_contract` | Create a milestone contract. Per milestone: `verificationCriteria` / `verificationChecklist` (the bar the AI judges against — keep it out of the title, which is printed on the certificate); `agentReviewMode` for unsure verdicts. Returns `contractId` + `inviteCode`. |
|
|
125
|
+
| `cascrow_submit_proof` | Submit proof text — verification starts by itself. Returns `proofId`; wait with `cascrow_watch_verification { proofId }`. |
|
|
126
|
+
| `cascrow_verify` | Start or re-run 5-model AI verification. If a run is already going (e.g. the one upload started), waits for it instead of failing. |
|
|
127
127
|
| `cascrow_get_proof_status` | Poll a submitted proof's verification result. |
|
|
128
|
-
| `cascrow_watch_verification` |
|
|
128
|
+
| `cascrow_watch_verification` | Block until verification completes — either submit + wait (`milestoneId` + `content`) or wait for an existing proof (`proofId`). Stops early and says why when waiting cannot help. |
|
|
129
129
|
| `cascrow_mcp_submit` | Submit proof and get an immediate verdict in one call — auto-releases funds and mints the NFT on approval. |
|
|
130
130
|
| `cascrow_verify_work` | Verify AI work with no escrow contract, from a GitHub PR, pasted code, or a base64-encoded screenshot. Returns a public report URL. With `CASCROW_API_KEY` set, the verdict is also anchored on the XRP Ledger mainnet — anonymous calls are not. |
|
|
131
131
|
| `cascrow_escrow_fund` | Fund a milestone with an EVM private key (fully autonomous). |
|
|
@@ -177,7 +177,7 @@ Show me the contract link and confidence scores at the end.
|
|
|
177
177
|
|
|
178
178
|
Claude will:
|
|
179
179
|
1. `cascrow_create_contract` — 2 milestones, `amountUSD: 0` (verification-only, no escrow)
|
|
180
|
-
2. Build each section → `cascrow_submit_proof` → `
|
|
180
|
+
2. Build each section → `cascrow_submit_proof` → `cascrow_watch_verification`
|
|
181
181
|
3. Return the contract link + per-milestone confidence scores
|
|
182
182
|
|
|
183
183
|
> Set `amountUSD: 0` for **verification-only mode** — the full 5-model AI pipeline runs without locking any funds. Useful for trustless proof of work without payment.
|
|
@@ -189,7 +189,7 @@ Cascrow supports fully autonomous agent-to-agent payments:
|
|
|
189
189
|
1. **Requester** creates contract (`cascrow_create_contract`) + funds it (`cascrow_escrow_fund` with EVM private key)
|
|
190
190
|
2. Requester sends work to **Builder** via `cascrow_handoff` using the Builder's Agent ID
|
|
191
191
|
3. Builder accepts via `cascrow_check_invites` + `cascrow_join_contract`
|
|
192
|
-
4. Builder completes work → `cascrow_submit_proof` → `
|
|
192
|
+
4. Builder completes work → `cascrow_submit_proof` → `cascrow_watch_verification`
|
|
193
193
|
5. On 3/5 YES: RLUSD released to Builder's wallet + NFT certificate minted on XRP Ledger
|
|
194
194
|
|
|
195
195
|
No humans required at any step.
|
package/index.js
CHANGED
|
@@ -43,47 +43,122 @@ if (ANONYMOUS_MODE) {
|
|
|
43
43
|
|
|
44
44
|
const authHeader = API_KEY ? { "Authorization": `Bearer ${API_KEY}` } : {};
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
46
|
+
// Client deadlines. This package cannot import src/, so the two that have an owner
|
|
47
|
+
// are MIRRORED here and checked against it by client-deadline-mirrors.test.ts:
|
|
48
|
+
// UPLOAD_CLIENT_ABORT_MS mirrors UPLOAD_CLIENT_ABORT_MS in src/lib/latency-budget.ts
|
|
49
|
+
// MCP_SUBMIT_CLIENT_ABORT_MS mirrors MCP_SUBMIT_CLIENT_ABORT_MS in src/lib/latency-budget.ts
|
|
50
|
+
// Every other call gets REQUEST_CLIENT_ABORT_MS — the longest maxDuration any route
|
|
51
|
+
// has, so it only ever stops a request the platform has already given up on. Before
|
|
52
|
+
// T11 these calls had no deadline at all: a hung connection hung the tool call.
|
|
53
|
+
const UPLOAD_CLIENT_ABORT_MS = 120_000;
|
|
54
|
+
const MCP_SUBMIT_CLIENT_ABORT_MS = 235_000;
|
|
55
|
+
const REQUEST_CLIENT_ABORT_MS = 300_000;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* An error from the API with what a caller needs to decide what to do next: the
|
|
59
|
+
* HTTP status, the stable `code` (src/lib/api-error.ts — or `UPSTREAM_NON_JSON` /
|
|
60
|
+
* `CLIENT_TIMEOUT`, which this client produces itself), and how long the server
|
|
61
|
+
* asked us to wait (`Retry-After`, else `retry_after_seconds`).
|
|
62
|
+
*/
|
|
63
|
+
class CascrowApiError extends Error {
|
|
64
|
+
constructor(message, { status, code = null, retryAfterSeconds = null, body = null } = {}) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.name = "CascrowApiError";
|
|
67
|
+
this.status = status;
|
|
68
|
+
this.code = code;
|
|
69
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
70
|
+
this.body = body;
|
|
58
71
|
}
|
|
59
|
-
return data;
|
|
60
72
|
}
|
|
61
73
|
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
74
|
+
function retryAfterOf(res, data) {
|
|
75
|
+
const header = res.headers && typeof res.headers.get === "function" ? res.headers.get("retry-after") : null;
|
|
76
|
+
const fromHeader = header !== null && /^\d+$/.test(header.trim()) ? Number(header.trim()) : null;
|
|
77
|
+
if (fromHeader !== null) return fromHeader;
|
|
78
|
+
return typeof data?.retry_after_seconds === "number" ? data.retry_after_seconds : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The one way this server talks to the API (T11-A3).
|
|
83
|
+
*
|
|
84
|
+
* The body is read as TEXT and parsed in a try: an HTML 502 from the platform used
|
|
85
|
+
* to reach `res.json()` and surface as "Unexpected token '<'", naming neither the
|
|
86
|
+
* status nor anything to do about it. The deadline covers the body read too — the
|
|
87
|
+
* timer is cleared after it, not after the headers (SEC-073's lesson). No retry
|
|
88
|
+
* here: a single call either answers or throws; only the poll loop waits and asks
|
|
89
|
+
* again.
|
|
90
|
+
*/
|
|
91
|
+
async function fetchJson(method, path, { json, form, deadlineMs = REQUEST_CLIENT_ABORT_MS } = {}) {
|
|
92
|
+
const controller = new AbortController();
|
|
93
|
+
const timer = setTimeout(() => controller.abort(), deadlineMs);
|
|
94
|
+
let res;
|
|
95
|
+
let text;
|
|
96
|
+
try {
|
|
97
|
+
res = await fetch(`${BASE_URL}${path}`, {
|
|
98
|
+
method,
|
|
99
|
+
headers: { ...authHeader, ...(json !== undefined ? { "Content-Type": "application/json" } : {}) },
|
|
100
|
+
...(json !== undefined ? { body: JSON.stringify(json) } : form !== undefined ? { body: form } : {}),
|
|
101
|
+
signal: controller.signal,
|
|
102
|
+
});
|
|
103
|
+
text = await res.text();
|
|
104
|
+
} catch (e) {
|
|
105
|
+
if (e && e.name === "AbortError") {
|
|
106
|
+
throw new CascrowApiError(
|
|
107
|
+
`No answer from ${path} within ${Math.round(deadlineMs / 1000)} s. The request may still complete on the server — check its result before repeating it.`,
|
|
108
|
+
{ status: 0, code: "CLIENT_TIMEOUT" }
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
throw e;
|
|
112
|
+
} finally {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
}
|
|
115
|
+
let data;
|
|
116
|
+
try {
|
|
117
|
+
data = text ? JSON.parse(text) : {};
|
|
118
|
+
} catch {
|
|
119
|
+
data = { error: `HTTP ${res.status} (non-JSON response from server)`, code: "UPSTREAM_NON_JSON" };
|
|
120
|
+
// A 200 that is not JSON is not a success anyone can read.
|
|
121
|
+
if (res.ok) throw new CascrowApiError(data.error, { status: res.status, code: data.code });
|
|
122
|
+
}
|
|
67
123
|
if (!res.ok) {
|
|
68
|
-
|
|
124
|
+
const code = typeof data?.code === "string" ? data.code : null;
|
|
125
|
+
const retryAfterSeconds = retryAfterOf(res, data);
|
|
126
|
+
const base = typeof data?.error === "string" && data.error ? data.error : `HTTP ${res.status}`;
|
|
127
|
+
// A platform error page on a POST (typically the 504 when a route hits its own
|
|
128
|
+
// maxDuration — escrow-fund's is 120 s) does not mean the call failed: the function
|
|
129
|
+
// may have moved money before it was killed. Say so, and how to check.
|
|
130
|
+
const mayHaveCompleted = method !== "GET" && res.status >= 500 && data.code === "UPSTREAM_NON_JSON";
|
|
131
|
+
throw new CascrowApiError(
|
|
132
|
+
`${base}${code ? ` [${code}]` : ""}${retryAfterSeconds !== null && (res.status === 429 || res.status === 503) ? ` — retry after ${retryAfterSeconds} s` : ""}` +
|
|
133
|
+
(mayHaveCompleted ? ". The request may still have completed on the server — check the contract (cascrow_get_contract) before repeating it." : ""),
|
|
134
|
+
{ status: res.status, code, retryAfterSeconds, body: data }
|
|
135
|
+
);
|
|
69
136
|
}
|
|
70
137
|
return data;
|
|
71
138
|
}
|
|
72
139
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
});
|
|
82
|
-
const data = await res.json();
|
|
83
|
-
if (!res.ok) {
|
|
84
|
-
throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
140
|
+
/** A response body parsed without trusting it to be JSON (see fetchJson). */
|
|
141
|
+
async function readJsonSafe(res) {
|
|
142
|
+
const text = await res.text();
|
|
143
|
+
if (!text) return {};
|
|
144
|
+
try {
|
|
145
|
+
return JSON.parse(text);
|
|
146
|
+
} catch {
|
|
147
|
+
return { error: `HTTP ${res.status} (non-JSON response from server)`, code: "UPSTREAM_NON_JSON" };
|
|
85
148
|
}
|
|
86
|
-
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The names the handlers (and the tests that extract them) use. Thin wrappers.
|
|
152
|
+
async function apiPost(path, body, deadlineMs) {
|
|
153
|
+
return fetchJson("POST", path, { json: body, deadlineMs });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function apiGet(path) {
|
|
157
|
+
return fetchJson("GET", path);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function apiPatch(path, body) {
|
|
161
|
+
return fetchJson("PATCH", path, { json: body });
|
|
87
162
|
}
|
|
88
163
|
|
|
89
164
|
async function uploadProof(milestoneId, filename, content) {
|
|
@@ -91,17 +166,7 @@ async function uploadProof(milestoneId, filename, content) {
|
|
|
91
166
|
const form = new FormData();
|
|
92
167
|
form.append("file", blob, filename);
|
|
93
168
|
form.append("milestoneId", milestoneId);
|
|
94
|
-
|
|
95
|
-
const res = await fetch(`${BASE_URL}/api/proof/upload`, {
|
|
96
|
-
method: "POST",
|
|
97
|
-
headers: { ...authHeader },
|
|
98
|
-
body: form,
|
|
99
|
-
});
|
|
100
|
-
const data = await res.json();
|
|
101
|
-
if (!res.ok) {
|
|
102
|
-
throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
103
|
-
}
|
|
104
|
-
return data;
|
|
169
|
+
return fetchJson("POST", "/api/proof/upload", { form, deadlineMs: UPLOAD_CLIENT_ABORT_MS });
|
|
105
170
|
}
|
|
106
171
|
|
|
107
172
|
// Stream verify with a 235s deadline that covers the WHOLE read, quorum retries
|
|
@@ -153,8 +218,24 @@ async function streamVerify(proofId) {
|
|
|
153
218
|
// long-lived MCP server that holds the event loop and later aborts a fetch
|
|
154
219
|
// that finished minutes ago.
|
|
155
220
|
clearTimeout(timeout);
|
|
156
|
-
const
|
|
157
|
-
|
|
221
|
+
const text = await res.text().catch(() => "");
|
|
222
|
+
let data;
|
|
223
|
+
try { data = text ? JSON.parse(text) : {}; } catch { data = { error: `HTTP ${res.status} (non-JSON response from server)`, code: "UPSTREAM_NON_JSON" }; }
|
|
224
|
+
// T01-5. Upload starts verification by itself, so a start that lost the claim to
|
|
225
|
+
// a RUNNING run is the normal case, not a failure: wait for that run instead.
|
|
226
|
+
// Never read this 409's Retry-After as a wait — it is the ~1 h takeover cutoff
|
|
227
|
+
// (SEC-093); `expected_done_in_seconds` is how long the run needs.
|
|
228
|
+
if (res.status === 409 && data.code === "VERIFICATION_IN_PROGRESS" && data.phase === "running") {
|
|
229
|
+
const budgetMs = Math.max(30_000, Math.min(235_000, ((data.expected_done_in_seconds ?? 200) + 30) * 1000));
|
|
230
|
+
const watch = await pollUntilSettled(proofId, budgetMs);
|
|
231
|
+
return { attached: true, watch: watch ?? stillRunningResult(proofId) };
|
|
232
|
+
}
|
|
233
|
+
throw new CascrowApiError(`${data.error ?? `HTTP ${res.status}`}${data.code ? ` [${data.code}]` : ""}`, {
|
|
234
|
+
status: res.status,
|
|
235
|
+
code: typeof data.code === "string" ? data.code : null,
|
|
236
|
+
retryAfterSeconds: retryAfterOf(res, data),
|
|
237
|
+
body: data,
|
|
238
|
+
});
|
|
158
239
|
}
|
|
159
240
|
if (!res.body) {
|
|
160
241
|
clearTimeout(timeout);
|
|
@@ -223,18 +304,86 @@ async function streamVerify(proofId) {
|
|
|
223
304
|
|
|
224
305
|
// ─── Tool definitions ─────────────────────────────────────────────────────────
|
|
225
306
|
|
|
307
|
+
// ─── The bar a milestone is judged against ────────────────────────────────────
|
|
308
|
+
// COPY of normalizeMilestoneChecklist / normalizeCriteria in src/lib/milestone-bar-input.ts
|
|
309
|
+
// (this package cannot import src/). milestone-bar-input-parity.test.ts runs this copy
|
|
310
|
+
// against the owner over the same case matrix — change both, or that test goes red.
|
|
311
|
+
const CHECKLIST_MAX_ITEMS = 20;
|
|
312
|
+
const CHECKLIST_TITLE_MAX = 200;
|
|
313
|
+
const CHECKLIST_ID_MAX = 100;
|
|
314
|
+
const CRITERIA_MAX = 4000;
|
|
315
|
+
const CHECKLIST_SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW"];
|
|
316
|
+
|
|
317
|
+
function normalizeMilestoneChecklist(items) {
|
|
318
|
+
if (items === undefined || items === null) return undefined;
|
|
319
|
+
if (!Array.isArray(items)) throw new Error("verificationChecklist: must be an array of { title, severity?, id? }");
|
|
320
|
+
if (items.length === 0) return undefined;
|
|
321
|
+
if (items.length > CHECKLIST_MAX_ITEMS) throw new Error(`verificationChecklist: at most ${CHECKLIST_MAX_ITEMS} items`);
|
|
322
|
+
items.forEach((raw, i) => {
|
|
323
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
324
|
+
throw new Error(`verificationChecklist[${i}]: must be an object with a title`);
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
const withId = items.filter((it) => it.id !== undefined).length;
|
|
328
|
+
if (withId !== 0 && withId !== items.length) {
|
|
329
|
+
throw new Error("verificationChecklist: either give every item an id or none (ids are then assigned 1..n)");
|
|
330
|
+
}
|
|
331
|
+
const seen = new Set();
|
|
332
|
+
return items.map((it, i) => {
|
|
333
|
+
const title = typeof it.title === "string" ? it.title.trim() : "";
|
|
334
|
+
if (title.length === 0) throw new Error(`verificationChecklist[${i}]: title is required`);
|
|
335
|
+
if (title.length > CHECKLIST_TITLE_MAX) throw new Error(`verificationChecklist[${i}]: title must be at most ${CHECKLIST_TITLE_MAX} characters`);
|
|
336
|
+
let id = i + 1;
|
|
337
|
+
if (withId) {
|
|
338
|
+
if (typeof it.id !== "number" || !Number.isInteger(it.id) || it.id < 1 || it.id > CHECKLIST_ID_MAX) {
|
|
339
|
+
throw new Error(`verificationChecklist[${i}]: id must be an integer from 1 to ${CHECKLIST_ID_MAX}`);
|
|
340
|
+
}
|
|
341
|
+
if (seen.has(it.id)) throw new Error(`verificationChecklist[${i}]: duplicate id ${it.id}`);
|
|
342
|
+
seen.add(it.id);
|
|
343
|
+
id = it.id;
|
|
344
|
+
}
|
|
345
|
+
const out = { id, title };
|
|
346
|
+
if (it.severity !== undefined && it.severity !== null && it.severity !== "") {
|
|
347
|
+
const sev = typeof it.severity === "string" ? it.severity.trim().toUpperCase() : "";
|
|
348
|
+
if (!CHECKLIST_SEVERITIES.includes(sev)) {
|
|
349
|
+
throw new Error(`verificationChecklist[${i}]: severity must be one of ${CHECKLIST_SEVERITIES.join(", ")}`);
|
|
350
|
+
}
|
|
351
|
+
out.severity = sev;
|
|
352
|
+
}
|
|
353
|
+
return out;
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function normalizeCriteria(value) {
|
|
358
|
+
if (value === undefined || value === null) return undefined;
|
|
359
|
+
if (typeof value !== "string") throw new Error("verificationCriteria: must be a string");
|
|
360
|
+
const v = value.trim();
|
|
361
|
+
if (v.length === 0) return undefined;
|
|
362
|
+
if (v.length > CRITERIA_MAX) throw new Error(`verificationCriteria: at most ${CRITERIA_MAX} characters (got ${v.length})`);
|
|
363
|
+
return v;
|
|
364
|
+
}
|
|
365
|
+
|
|
226
366
|
const TOOLS = [
|
|
227
367
|
{
|
|
228
368
|
name: "cascrow_create_contract",
|
|
229
369
|
description:
|
|
230
370
|
"Create a Cascrow verification contract with one or more milestones. " +
|
|
231
|
-
"Each milestone
|
|
232
|
-
"
|
|
371
|
+
"Each milestone has a short title, a deadline and — separately — the bar the AI panel judges it against: " +
|
|
372
|
+
"verificationCriteria (prose) and/or verificationChecklist (items). Put acceptance criteria THERE, not in the title: " +
|
|
373
|
+
"the title is printed on the public certificate. " +
|
|
374
|
+
"Returns contractId, an inviteCode and a link to view the contract. " +
|
|
233
375
|
"Use this BEFORE starting any work so the user can see the plan.",
|
|
234
376
|
inputSchema: {
|
|
235
377
|
type: "object",
|
|
236
378
|
required: ["milestones"],
|
|
237
379
|
properties: {
|
|
380
|
+
agentReviewMode: {
|
|
381
|
+
type: "string",
|
|
382
|
+
enum: ["AUTO", "MANUAL", "MANUAL_AUTO"],
|
|
383
|
+
description:
|
|
384
|
+
"What happens when the AI panel is unsure (confidence 60–85). AUTO (default) rejects; MANUAL waits for your decision " +
|
|
385
|
+
"via cascrow_review_contract; MANUAL_AUTO waits 48 h for your decision, then approves.",
|
|
386
|
+
},
|
|
238
387
|
milestones: {
|
|
239
388
|
type: "array",
|
|
240
389
|
minItems: 1,
|
|
@@ -245,7 +394,30 @@ const TOOLS = [
|
|
|
245
394
|
properties: {
|
|
246
395
|
title: {
|
|
247
396
|
type: "string",
|
|
248
|
-
description: "
|
|
397
|
+
description: "Short milestone title, e.g. 'Landing page hero section'. Printed on the public certificate — put the acceptance criteria in verificationCriteria / verificationChecklist instead.",
|
|
398
|
+
},
|
|
399
|
+
verificationCriteria: {
|
|
400
|
+
type: "string",
|
|
401
|
+
maxLength: 4000,
|
|
402
|
+
description:
|
|
403
|
+
"What the AI panel checks the proof against, in prose, e.g. 'Responsive down to 320px, LCP under 2 s on mobile, CTA links to /signup'. " +
|
|
404
|
+
"The panel judges the proof against this text; without it, it judges against the title alone.",
|
|
405
|
+
},
|
|
406
|
+
verificationChecklist: {
|
|
407
|
+
type: "array",
|
|
408
|
+
maxItems: 20,
|
|
409
|
+
description:
|
|
410
|
+
"Optional checklist the panel scores item by item on TEXT proofs (a YES then needs every item addressed — stricter than criteria alone). " +
|
|
411
|
+
"Image proofs are judged against the criteria, not the checklist. Give every item an id (1–100, unique) or none (ids are assigned 1..n).",
|
|
412
|
+
items: {
|
|
413
|
+
type: "object",
|
|
414
|
+
required: ["title"],
|
|
415
|
+
properties: {
|
|
416
|
+
id: { type: "integer", minimum: 1, maximum: 100 },
|
|
417
|
+
title: { type: "string", maxLength: 200 },
|
|
418
|
+
severity: { type: "string", enum: ["CRITICAL", "HIGH", "MEDIUM", "LOW"] },
|
|
419
|
+
},
|
|
420
|
+
},
|
|
249
421
|
},
|
|
250
422
|
deadlineDays: {
|
|
251
423
|
type: "number",
|
|
@@ -303,8 +475,9 @@ const TOOLS = [
|
|
|
303
475
|
"Write a detailed, specific proof report describing exactly what was built, " +
|
|
304
476
|
"with verifiable evidence (URLs, metrics, specific implementation details). " +
|
|
305
477
|
"The more specific and verifiable the proof, the higher the AI confidence score. " +
|
|
306
|
-
"NOTE: After submitting, the backend
|
|
307
|
-
"
|
|
478
|
+
"NOTE: After submitting, the backend starts AI verification by itself. " +
|
|
479
|
+
"Wait for the verdict with cascrow_watch_verification { proofId } (or poll cascrow_get_proof_status). " +
|
|
480
|
+
"Do NOT call cascrow_verify for a proof you just submitted — its verification is already running.",
|
|
308
481
|
inputSchema: {
|
|
309
482
|
type: "object",
|
|
310
483
|
required: ["milestoneId", "proof"],
|
|
@@ -324,10 +497,11 @@ const TOOLS = [
|
|
|
324
497
|
{
|
|
325
498
|
name: "cascrow_verify",
|
|
326
499
|
description:
|
|
327
|
-
"
|
|
500
|
+
"Start (or re-run) AI verification for a proof and return the result. " +
|
|
328
501
|
"5 AI models vote in parallel (Claude, GPT-4o, Gemini, Mistral, Cerebras). " +
|
|
329
502
|
"Returns decision, confidence score, action (VERIFIED/PENDING_REVIEW/REJECTED), and per-model votes. " +
|
|
330
|
-
"
|
|
503
|
+
"Upload already starts verification by itself: for a proof you just submitted use cascrow_watch_verification { proofId } instead. " +
|
|
504
|
+
"If a verification of this proof is already running, this call waits for it rather than failing. " +
|
|
331
505
|
"Confidence > 85 + YES = VERIFIED. Confidence 60–85 = PENDING_REVIEW (manual review needed). Confidence < 60 = REJECTED.",
|
|
332
506
|
inputSchema: {
|
|
333
507
|
type: "object",
|
|
@@ -636,17 +810,22 @@ const TOOLS = [
|
|
|
636
810
|
{
|
|
637
811
|
name: "cascrow_watch_verification",
|
|
638
812
|
description:
|
|
639
|
-
"
|
|
640
|
-
"
|
|
813
|
+
"Wait for AI verification to complete — either submit a proof and wait (milestoneId + content), " +
|
|
814
|
+
"or wait for a proof you already submitted (proofId). Give one mode, not both. " +
|
|
641
815
|
"Returns the final YES/NO decision, confidence score, and per-model votes once verification is done. " +
|
|
642
|
-
"
|
|
816
|
+
"Stops early when waiting cannot help (proof deleted, no access, run interrupted) and says why. " +
|
|
817
|
+
"Times out after ~100 seconds and returns the proofId so you can call it again or poll cascrow_get_proof_status.",
|
|
643
818
|
inputSchema: {
|
|
644
819
|
type: "object",
|
|
645
|
-
required: [
|
|
820
|
+
required: [],
|
|
646
821
|
properties: {
|
|
822
|
+
proofId: {
|
|
823
|
+
type: "string",
|
|
824
|
+
description: "Wait for this already-submitted proof (e.g. from cascrow_submit_proof). Omit milestoneId and content in this mode.",
|
|
825
|
+
},
|
|
647
826
|
milestoneId: {
|
|
648
827
|
type: "string",
|
|
649
|
-
description: "
|
|
828
|
+
description: "Submit mode: the milestone ID to submit proof for",
|
|
650
829
|
},
|
|
651
830
|
content: {
|
|
652
831
|
type: "string",
|
|
@@ -663,7 +842,7 @@ const TOOLS = [
|
|
|
663
842
|
|
|
664
843
|
// ─── Tool handlers ────────────────────────────────────────────────────────────
|
|
665
844
|
|
|
666
|
-
async function handleCreateContract({ milestones }) {
|
|
845
|
+
async function handleCreateContract({ milestones, agentReviewMode }) {
|
|
667
846
|
const deadline = (days) => {
|
|
668
847
|
const d = new Date();
|
|
669
848
|
d.setDate(d.getDate() + (days ?? 7));
|
|
@@ -671,11 +850,26 @@ async function handleCreateContract({ milestones }) {
|
|
|
671
850
|
};
|
|
672
851
|
|
|
673
852
|
const payload = {
|
|
674
|
-
milestones: milestones.map((m) =>
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
853
|
+
milestones: milestones.map((m, i) => {
|
|
854
|
+
// Normalised here so a bad item is refused with its milestone and item index,
|
|
855
|
+
// not as a 400 on the whole contract. Copy of src/lib/milestone-bar-input.ts;
|
|
856
|
+
// milestone-bar-input-parity.test.ts runs this copy against the owner.
|
|
857
|
+
let criteria, checklist;
|
|
858
|
+
try {
|
|
859
|
+
criteria = normalizeCriteria(m.verificationCriteria);
|
|
860
|
+
checklist = normalizeMilestoneChecklist(m.verificationChecklist);
|
|
861
|
+
} catch (e) {
|
|
862
|
+
throw new Error(`milestones[${i}]: ${e.message}`);
|
|
863
|
+
}
|
|
864
|
+
return {
|
|
865
|
+
title: m.title,
|
|
866
|
+
amountUSD: m.amountUSD ?? 0,
|
|
867
|
+
cancelAfter: deadline(m.deadlineDays),
|
|
868
|
+
...(criteria !== undefined ? { verificationCriteria: criteria } : {}),
|
|
869
|
+
...(checklist !== undefined ? { verificationChecklist: checklist } : {}),
|
|
870
|
+
};
|
|
871
|
+
}),
|
|
872
|
+
...(agentReviewMode !== undefined ? { agentReviewMode } : {}),
|
|
679
873
|
};
|
|
680
874
|
|
|
681
875
|
const data = await apiPost("/api/contracts", payload);
|
|
@@ -727,12 +921,17 @@ async function handleSubmitProof({ milestoneId, proof, filename }) {
|
|
|
727
921
|
);
|
|
728
922
|
return {
|
|
729
923
|
proofId: data.proofId,
|
|
730
|
-
message:
|
|
924
|
+
message:
|
|
925
|
+
`Proof submitted (proofId: ${data.proofId}). AI verification has started automatically. ` +
|
|
926
|
+
`Wait for the verdict with cascrow_watch_verification { proofId: "${data.proofId}" } — do not call cascrow_verify, it is already running.`,
|
|
731
927
|
};
|
|
732
928
|
}
|
|
733
929
|
|
|
734
930
|
async function handleVerify({ proofId }) {
|
|
735
931
|
const result = await streamVerify(proofId);
|
|
932
|
+
// The run was already going (usually the one upload started): its result, read
|
|
933
|
+
// from proof-status through the same formatters as cascrow_watch_verification.
|
|
934
|
+
if (result && result.attached) return result.watch;
|
|
736
935
|
const decision = result.decision ?? "?";
|
|
737
936
|
const confidence = result.confidence ?? 0;
|
|
738
937
|
const action = result.action ?? "?";
|
|
@@ -810,7 +1009,12 @@ async function handleVerify({ proofId }) {
|
|
|
810
1009
|
const txHash = result.txHash ?? null;
|
|
811
1010
|
let statusLine;
|
|
812
1011
|
if (superseded) {
|
|
813
|
-
|
|
1012
|
+
// `settledAs` names a terminal row (e.g. EXPIRED) the verdict lost to, not a
|
|
1013
|
+
// competing proof — "another proof won" would send the agent to resubmit.
|
|
1014
|
+
const settledAs = result.settledAs;
|
|
1015
|
+
statusLine = settledAs
|
|
1016
|
+
? `ℹ️ SUPERSEDED — this milestone was closed (${settledAs}) before this verification could settle it, so this verification changed nothing and moved no funds. Your evidence was recorded. Do not resubmit to this milestone.`
|
|
1017
|
+
: `ℹ️ SUPERSEDED — another proof on this milestone was decided first, so this verification did not change its state. Your evidence was recorded. Check the milestone status before resubmitting.`;
|
|
814
1018
|
} else if (released) {
|
|
815
1019
|
statusLine = alreadyReleased
|
|
816
1020
|
? `✅ COMPLETED — this milestone was already released by another request. The funds are out; do not resubmit.`
|
|
@@ -853,6 +1057,9 @@ async function handleVerify({ proofId }) {
|
|
|
853
1057
|
released,
|
|
854
1058
|
alreadyReleased,
|
|
855
1059
|
superseded,
|
|
1060
|
+
// Returned as well as rendered, as the hosted route does: with SUPERSEDED it says
|
|
1061
|
+
// the row was CLOSED rather than won by another proof.
|
|
1062
|
+
...(result.settledAs ? { settledAs: result.settledAs } : {}),
|
|
856
1063
|
releaseHoldUntil,
|
|
857
1064
|
reasoning,
|
|
858
1065
|
totalModels: TOTAL_MODELS,
|
|
@@ -1255,8 +1462,19 @@ async function handleListMyContracts({ role } = {}) {
|
|
|
1255
1462
|
* another one and the milestone is in someone else's terminal state.
|
|
1256
1463
|
*/
|
|
1257
1464
|
function proofOutcomeLine(s) {
|
|
1465
|
+
// FIRST, before `decision` is read at all: a milestone that is already terminal
|
|
1466
|
+
// cannot be moved by this proof's verdict. Since 2026-09-23 `/api/verify` leaves a
|
|
1467
|
+
// row that went EXPIRED mid-panel EXPIRED and still persists the verdict, so a YES
|
|
1468
|
+
// here used to print "✅ APPROVED" for money that may be back with the funder.
|
|
1469
|
+
const closed = closedMilestoneStatus(s);
|
|
1470
|
+
if (closed && closed !== "COMPLETED") return closedOutcomeLine(s, closed);
|
|
1471
|
+
// SECOND: the milestone is approved (VERIFIED) or paid (COMPLETED), and not on a YES
|
|
1472
|
+
// from this proof. Its `decision` is then NO or absent, and printing it next to
|
|
1473
|
+
// "COMPLETED — funds released" or "APPROVED — payout scheduled" read as THIS proof
|
|
1474
|
+
// having been approved.
|
|
1475
|
+
if (approvedWithoutThisVerdict(s)) return approvedWithoutThisVerdictLine(s);
|
|
1258
1476
|
const head =
|
|
1259
|
-
s.milestoneStatus === "COMPLETED"
|
|
1477
|
+
s.milestoneStatus === "COMPLETED" || closed === "COMPLETED"
|
|
1260
1478
|
? "✅ COMPLETED — funds released"
|
|
1261
1479
|
: s.milestoneStatus === "VERIFIED" && s.releaseHoldUntil
|
|
1262
1480
|
? `⏸️ APPROVED — payout scheduled for ${s.releaseHoldUntil}, no funds have moved yet`
|
|
@@ -1266,6 +1484,61 @@ function proofOutcomeLine(s) {
|
|
|
1266
1484
|
return `${head}${s.confidence != null ? ` (confidence: ${s.confidence}%)` : ""}`;
|
|
1267
1485
|
}
|
|
1268
1486
|
|
|
1487
|
+
/**
|
|
1488
|
+
* The terminal status of the row this proof settles against, or null while it is open.
|
|
1489
|
+
*
|
|
1490
|
+
* `milestoneClosed` is what `/api/agent/proof-status` derives from the row (the
|
|
1491
|
+
* milestone, or the contract on the legacy contract-level path). `milestoneStatus` is
|
|
1492
|
+
* the fallback for a server that predates the field — the same two statuses, so an
|
|
1493
|
+
* older deployment does not reopen the defect this exists for.
|
|
1494
|
+
*/
|
|
1495
|
+
function closedMilestoneStatus(s) {
|
|
1496
|
+
if (s.milestoneClosed === true) return s.terminalStatus || s.milestoneStatus || s.contractStatus || "EXPIRED";
|
|
1497
|
+
// A legacy contract-level proof has no milestone: an older server reports its row only
|
|
1498
|
+
// as `contractStatus`, and reading `milestoneStatus` alone printed APPROVED for a
|
|
1499
|
+
// proof whose contract had EXPIRED.
|
|
1500
|
+
const row = s.milestoneStatus ?? s.contractStatus;
|
|
1501
|
+
return row === "EXPIRED" || row === "COMPLETED" ? row : null;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
/**
|
|
1505
|
+
* What happened to the money, from `terminalReason`. EXPIRED is not one story:
|
|
1506
|
+
* `RELEASED_ON_CHAIN` means the chain shows the builder WAS paid (just not by this
|
|
1507
|
+
* verification); the others mean the funder got the escrow back, or none existed. The
|
|
1508
|
+
* vocabulary is `src/lib/terminal-reason.ts`; an absent or unknown value says so
|
|
1509
|
+
* rather than guessing.
|
|
1510
|
+
*/
|
|
1511
|
+
function terminalStory(reason, row) {
|
|
1512
|
+
switch (reason) {
|
|
1513
|
+
case "RELEASED_ON_CHAIN":
|
|
1514
|
+
return "The chain shows this milestone's escrow WAS released to the builder before it was closed — the builder was paid, but not by this verification.";
|
|
1515
|
+
case "CANCELLED_AFTER_APPROVAL":
|
|
1516
|
+
return "An earlier approval lost the race to the funder's on-chain cancel; the escrow was refunded to the funder.";
|
|
1517
|
+
case "DEADLINE_EXPIRED":
|
|
1518
|
+
return "The deadline passed and the escrow was cancelled, refunding the funder.";
|
|
1519
|
+
case "RENEGOTIATION_REJECTED":
|
|
1520
|
+
return "A requested extension was rejected and the escrow was cancelled, refunding the funder.";
|
|
1521
|
+
case "NO_FUNDED_ESCROW":
|
|
1522
|
+
return "No funded escrow existed on-chain for this milestone.";
|
|
1523
|
+
case "CONTRACT_DECLINED":
|
|
1524
|
+
return "The contract was declined or expired before anything was escrowed.";
|
|
1525
|
+
default:
|
|
1526
|
+
return `The reason was not recorded — check the ${row} before assuming where the funds are.`;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/** The outcome line for a proof whose milestone is terminal and not COMPLETED. */
|
|
1531
|
+
function closedOutcomeLine(s, closed) {
|
|
1532
|
+
const row = proofRowNoun(s);
|
|
1533
|
+
const verdict = s.decision
|
|
1534
|
+
? `The panel's verdict on this proof was ${s.decision}${s.confidence != null ? ` (confidence: ${s.confidence}%)` : ""}, and it changed nothing.`
|
|
1535
|
+
: "No verdict was recorded for this proof.";
|
|
1536
|
+
return (
|
|
1537
|
+
`⛔ CLOSED — the ${row} was closed (${closed}) while the panel ran or after it decided, so this verification moved no funds. ` +
|
|
1538
|
+
`${terminalStory(s.terminalReason, row)} ${verdict} There is nothing to poll for and nothing to resubmit to this ${row}.`
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1269
1542
|
|
|
1270
1543
|
/**
|
|
1271
1544
|
* One `verify/standalone` call under a client deadline (SEC-073).
|
|
@@ -1304,13 +1577,75 @@ async function withStandaloneDeadline(run) {
|
|
|
1304
1577
|
}
|
|
1305
1578
|
|
|
1306
1579
|
/**
|
|
1307
|
-
*
|
|
1308
|
-
*
|
|
1309
|
-
*
|
|
1310
|
-
|
|
1580
|
+
* "milestone", or "contract" for a legacy contract-level proof — which has no milestone,
|
|
1581
|
+
* so a message telling the agent to "check the milestone" sent it looking for nothing.
|
|
1582
|
+
* `milestoneStatus` is null exactly when the proof has no milestone row.
|
|
1583
|
+
*/
|
|
1584
|
+
function proofRowNoun(s) {
|
|
1585
|
+
return s.milestoneStatus == null ? "contract" : "milestone";
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
/**
|
|
1589
|
+
* True when the milestone is approved (VERIFIED) or paid (COMPLETED) and this proof has
|
|
1590
|
+
* no YES of its own: another proof won it, or the funder approved it in review — which
|
|
1591
|
+
* leaves this proof's `aiDecision` untouched (NO, or null after a panel outage).
|
|
1592
|
+
*
|
|
1593
|
+
* Either way nothing is left for THIS proof to decide. `/api/verify`'s claim excludes
|
|
1594
|
+
* VERIFIED and COMPLETED for every verdict, so a pending proof here can never move the
|
|
1595
|
+
* row: polling it burned the whole watch timeout, and `get_proof_status` said "poll
|
|
1596
|
+
* again" for good. `/api/verify`'s SSE answered SUPERSEDED for the same state.
|
|
1597
|
+
*/
|
|
1598
|
+
function approvedWithoutThisVerdict(s) {
|
|
1599
|
+
// Except while this proof's panel is RUNNING on a VERIFIED row. VERIFIED is not sticky:
|
|
1600
|
+
// a definite release failure rolls it back, `contracts/review` hands it back to
|
|
1601
|
+
// PENDING_REVIEW and the cron's hand-back blocks do the same, and a running claim then
|
|
1602
|
+
// decides the row after all. COMPLETED is terminal and stays final either way.
|
|
1603
|
+
// `running` is absent on a server that predates it, which keeps the old reading.
|
|
1604
|
+
const row = closedMilestoneStatus(s) === "COMPLETED" ? "COMPLETED" : (s.milestoneStatus ?? s.contractStatus);
|
|
1605
|
+
if (s.decision === "YES") return false;
|
|
1606
|
+
return row === "COMPLETED" || (row === "VERIFIED" && s.running !== true);
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
/** Final: no later poll can change what this proof status says. */
|
|
1610
|
+
function nothingLeftToDecide(s) {
|
|
1611
|
+
return closedMilestoneStatus(s) !== null || approvedWithoutThisVerdict(s);
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/** The outcome line for `approvedWithoutThisVerdict`. It never says THIS proof was approved. */
|
|
1615
|
+
function approvedWithoutThisVerdictLine(s) {
|
|
1616
|
+
const row = proofRowNoun(s);
|
|
1617
|
+
const ROW = row.toUpperCase();
|
|
1618
|
+
const head =
|
|
1619
|
+
closedMilestoneStatus(s) === "COMPLETED"
|
|
1620
|
+
? `✅ ${ROW} COMPLETED — funds released`
|
|
1621
|
+
: s.releaseHoldUntil
|
|
1622
|
+
? `⏸️ ${ROW} VERIFIED — payout scheduled for ${s.releaseHoldUntil}, no funds have moved yet (the funder can contest until then)`
|
|
1623
|
+
: `✅ ${ROW} VERIFIED — the payout is not confirmed yet`;
|
|
1624
|
+
const verdict =
|
|
1625
|
+
s.decision === "NO"
|
|
1626
|
+
? `The panel did NOT approve this proof (NO${s.confidence != null ? `, confidence: ${s.confidence}%` : ""}).`
|
|
1627
|
+
: "This proof has no panel verdict of its own.";
|
|
1628
|
+
return (
|
|
1629
|
+
`${head}, but not on this proof's verdict: another proof on this ${row}, or the funder's review, approved it. ` +
|
|
1630
|
+
`${verdict} Nothing is left for this proof to decide — do not poll it and do not resubmit to this ${row}.`
|
|
1631
|
+
);
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
/**
|
|
1635
|
+
* `pending` means "no verdict on this proof": `aiDecision` is null (not claimed yet, or
|
|
1636
|
+
* the panel never reached a verdict and the proof was parked for human review with
|
|
1637
|
+
* `aiDecision` deliberately left null) or the claim sentinel "PENDING" (`/api/verify`'s
|
|
1638
|
+
* panel is running on it). Those are NOT the same thing: told to keep polling, an agent
|
|
1639
|
+
* polls FOREVER on a parked proof — and told to stop, it abandons a running one.
|
|
1640
|
+
*
|
|
1641
|
+
* `running` separates them. A proof whose panel is RUNNING is not parked even on a
|
|
1642
|
+
* PENDING_REVIEW row: a sibling proof in the 60-85 band parked the milestone there while
|
|
1643
|
+
* this one's panel still runs, and this one's claim can overwrite PENDING_REVIEW and go
|
|
1644
|
+
* on to COMPLETED. Only a server that sends `running` can say so; without it the old
|
|
1645
|
+
* reading stays.
|
|
1311
1646
|
*/
|
|
1312
1647
|
function parkedForHuman(s) {
|
|
1313
|
-
return Boolean(s.pending) && s.milestoneStatus === "PENDING_REVIEW";
|
|
1648
|
+
return Boolean(s.pending) && s.running !== true && s.milestoneStatus === "PENDING_REVIEW";
|
|
1314
1649
|
}
|
|
1315
1650
|
|
|
1316
1651
|
const PARKED_MESSAGE =
|
|
@@ -1321,18 +1656,35 @@ const PARKED_MESSAGE =
|
|
|
1321
1656
|
async function handleGetProofStatus({ proofId }) {
|
|
1322
1657
|
const data = await apiGet(`/api/agent/proof-status/${encodeURIComponent(proofId)}`);
|
|
1323
1658
|
|
|
1324
|
-
// `pending` means
|
|
1325
|
-
// are NOT the same thing: verification is still running
|
|
1326
|
-
// verdict and the proof was parked for
|
|
1327
|
-
// left null (`agent/proof-status` keeps it
|
|
1328
|
-
// in a few seconds", an agent polls FOREVER
|
|
1329
|
-
// separates them: a milestone already at
|
|
1330
|
-
//
|
|
1331
|
-
//
|
|
1332
|
-
|
|
1659
|
+
// `pending` means "no verdict on this proof", which has two causes that read the same
|
|
1660
|
+
// and are NOT the same thing: verification is still running (the claim sentinel, or
|
|
1661
|
+
// not claimed yet), or the panel never reached a verdict and the proof was parked for
|
|
1662
|
+
// human review with `aiDecision` deliberately left null (`agent/proof-status` keeps it
|
|
1663
|
+
// null by design there). Told to "poll again in a few seconds", an agent polls FOREVER
|
|
1664
|
+
// on the second one. `milestoneStatus` separates them: a milestone already at
|
|
1665
|
+
// PENDING_REVIEW is waiting on a person — unless `running` says this proof's own panel
|
|
1666
|
+
// is still going, which can still decide that row (`parkedForHuman`).
|
|
1667
|
+
// `src/app/api/mcp/route.ts` has split these since SEC-010; this file, the published
|
|
1668
|
+
// one, did not.
|
|
1669
|
+
//
|
|
1670
|
+
// A CLOSED milestone is asked first: an outage that lost to EXPIRED leaves
|
|
1671
|
+
// `aiDecision` null for good on a row no verdict can move, and "poll again" would
|
|
1672
|
+
// loop forever on it exactly like the parked case.
|
|
1673
|
+
if (data.pending && !nothingLeftToDecide(data)) {
|
|
1333
1674
|
if (parkedForHuman(data)) {
|
|
1334
1675
|
return { ...data, message: `Proof ${proofId}: ${PARKED_MESSAGE}` };
|
|
1335
1676
|
}
|
|
1677
|
+
// An interrupted run (claimPhase "orphaned") reads as `pending` too, and "poll
|
|
1678
|
+
// again" would send the agent into a loop nothing ends — the poll loops stop on
|
|
1679
|
+
// it; this single read has to say it as well (F-50: same field, two doors).
|
|
1680
|
+
if (data.claimPhase === "orphaned") {
|
|
1681
|
+
return {
|
|
1682
|
+
...data,
|
|
1683
|
+
message:
|
|
1684
|
+
`The last verification run for proof ${proofId} was interrupted and nothing is running now. ` +
|
|
1685
|
+
"Once its claim is an hour old, cascrow_verify can start it again — or submit the proof again, which always works.",
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1336
1688
|
return {
|
|
1337
1689
|
...data,
|
|
1338
1690
|
message: `Proof ${proofId} is still being processed by the AI. Poll again in a few seconds.`,
|
|
@@ -1360,7 +1712,7 @@ async function handleGetProofStatus({ proofId }) {
|
|
|
1360
1712
|
}
|
|
1361
1713
|
|
|
1362
1714
|
async function handleMcpSubmit({ contract_id, milestone_id, evidence }) {
|
|
1363
|
-
const data = await apiPost("/api/mcp/submit", { contract_id, milestone_id, evidence });
|
|
1715
|
+
const data = await apiPost("/api/mcp/submit", { contract_id, milestone_id, evidence }, MCP_SUBMIT_CLIENT_ABORT_MS);
|
|
1364
1716
|
|
|
1365
1717
|
const confidence = data.confidence ?? 0;
|
|
1366
1718
|
const onChainUrl = data.on_chain_url ?? null;
|
|
@@ -1376,8 +1728,13 @@ async function handleMcpSubmit({ contract_id, milestone_id, evidence }) {
|
|
|
1376
1728
|
// `src/app/api/mcp/route.ts` has handled this since someone hit it once already;
|
|
1377
1729
|
// this file, the published one, never did.
|
|
1378
1730
|
if (data.decided === "elsewhere") {
|
|
1731
|
+
// `settledAs` names a TERMINAL row (EXPIRED, COMPLETED). EXPIRED is a funder cancel or
|
|
1732
|
+
// the deadline cron, not "the investor's review, or a concurrent submission" — and the
|
|
1733
|
+
// wording must not claim WHEN it closed relative to this submission.
|
|
1734
|
+
const settledAs = typeof data.settledAs === "string" ? data.settledAs : null;
|
|
1379
1735
|
return {
|
|
1380
1736
|
verdict: "decided_elsewhere",
|
|
1737
|
+
...(settledAs ? { settledAs } : {}),
|
|
1381
1738
|
confidence: null,
|
|
1382
1739
|
reasoning: "",
|
|
1383
1740
|
...markVerificationResult({ model_votes: [] }),
|
|
@@ -1385,10 +1742,12 @@ async function handleMcpSubmit({ contract_id, milestone_id, evidence }) {
|
|
|
1385
1742
|
proof_id: proofId,
|
|
1386
1743
|
release_hold_until: null,
|
|
1387
1744
|
signed_at: null,
|
|
1388
|
-
message:
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
"
|
|
1745
|
+
message: settledAs
|
|
1746
|
+
? `ℹ️ This milestone was closed (${settledAs}) before this submission could settle it, so this submission changed nothing and moved no funds itself. ` +
|
|
1747
|
+
"Your evidence was recorded. Do not resubmit to this milestone."
|
|
1748
|
+
: "ℹ️ This milestone was decided by another request while your proof was being verified — " +
|
|
1749
|
+
"by the investor's review, or by a concurrent submission. Your evidence was recorded. " +
|
|
1750
|
+
"Check the milestone status before resubmitting; a resubmission will be refused if it is already settled.",
|
|
1392
1751
|
};
|
|
1393
1752
|
}
|
|
1394
1753
|
|
|
@@ -1489,7 +1848,7 @@ async function handleVerifyWork({ taskDescription, prUrl, codeText, imageBase64,
|
|
|
1489
1848
|
// The body is read INSIDE the deadline. A response whose headers arrived
|
|
1490
1849
|
// and whose body then stalls is otherwise unbounded — a deadline that ends
|
|
1491
1850
|
// at the headers bounds time-to-first-byte and nothing else (SEC-073).
|
|
1492
|
-
return { res: r, data: await r
|
|
1851
|
+
return { res: r, data: await readJsonSafe(r) };
|
|
1493
1852
|
});
|
|
1494
1853
|
if (!res.ok) throw standaloneFailure(res.status, body);
|
|
1495
1854
|
// Assigned to the OUTER `data`, not shadowing it: `const { data }` inside the
|
|
@@ -1511,7 +1870,7 @@ async function handleVerifyWork({ taskDescription, prUrl, codeText, imageBase64,
|
|
|
1511
1870
|
// The body is read INSIDE the deadline. A response whose headers arrived
|
|
1512
1871
|
// and whose body then stalls is otherwise unbounded — a deadline that ends
|
|
1513
1872
|
// at the headers bounds time-to-first-byte and nothing else (SEC-073).
|
|
1514
|
-
return { res: r, data: await r
|
|
1873
|
+
return { res: r, data: await readJsonSafe(r) };
|
|
1515
1874
|
});
|
|
1516
1875
|
if (!res.ok) throw standaloneFailure(res.status, body);
|
|
1517
1876
|
// Assigned to the OUTER `data`, not shadowing it: `const { data }` inside the
|
|
@@ -1545,28 +1904,52 @@ async function handleVerifyWork({ taskDescription, prUrl, codeText, imageBase64,
|
|
|
1545
1904
|
};
|
|
1546
1905
|
}
|
|
1547
1906
|
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1907
|
+
/**
|
|
1908
|
+
* Poll proof-status until the proof has an outcome worth returning, or `maxMs` runs
|
|
1909
|
+
* out (then `null`). Shared by cascrow_watch_verification (both modes) and by
|
|
1910
|
+
* cascrow_verify when its start lost to a run already going (T01-5).
|
|
1911
|
+
*
|
|
1912
|
+
* What stops the loop early, and why each is safe:
|
|
1913
|
+
* - 401 / 403: waiting does not change who you are.
|
|
1914
|
+
* - 404: permanent. proof/upload answers with a proofId only after the Proof row
|
|
1915
|
+
* exists, and proof-status reads the primary database (there is no replica), so
|
|
1916
|
+
* a 404 means the proof was deleted or erased, not "not there yet".
|
|
1917
|
+
* - `claimPhase: "orphaned"`: the run that held the claim is dead; nothing is
|
|
1918
|
+
* running to wait for.
|
|
1919
|
+
* 429 / 503 wait at least what the server asked (Retry-After), never past `maxMs`.
|
|
1920
|
+
* Anything else — a network error, a 5xx — is transient: poll again.
|
|
1921
|
+
*/
|
|
1922
|
+
async function pollUntilSettled(proofId, maxMs = 100_000, pollMs = 5_000) {
|
|
1559
1923
|
const started = Date.now();
|
|
1560
|
-
|
|
1561
|
-
while (Date.now() - started <
|
|
1562
|
-
await new Promise((r) => setTimeout(r,
|
|
1924
|
+
let waitMs = pollMs;
|
|
1925
|
+
while (Date.now() - started < maxMs) {
|
|
1926
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
1927
|
+
waitMs = pollMs;
|
|
1563
1928
|
let status;
|
|
1564
1929
|
try {
|
|
1565
1930
|
status = await apiGet(`/api/agent/proof-status/${encodeURIComponent(proofId)}`);
|
|
1566
|
-
} catch {
|
|
1567
|
-
|
|
1931
|
+
} catch (e) {
|
|
1932
|
+
const code = e && typeof e.status === "number" ? e.status : null;
|
|
1933
|
+
if (code === 401 || code === 403 || code === 404) {
|
|
1934
|
+
return {
|
|
1935
|
+
pending: true,
|
|
1936
|
+
proofId,
|
|
1937
|
+
stopped: { status: code, code: e.code ?? null },
|
|
1938
|
+
message:
|
|
1939
|
+
code === 404
|
|
1940
|
+
? `Stopped waiting: proof ${proofId} no longer exists (it was deleted or erased). Nothing will verify it.`
|
|
1941
|
+
: `Stopped waiting for proof ${proofId}: ${e.message}. Waiting does not change this — check the API key and that this account is a party to the contract.`,
|
|
1942
|
+
};
|
|
1943
|
+
}
|
|
1944
|
+
if ((code === 429 || code === 503) && typeof e.retryAfterSeconds === "number") {
|
|
1945
|
+
// At least what the server asked, never past this loop's own budget.
|
|
1946
|
+
waitMs = Math.min(Math.max(pollMs, e.retryAfterSeconds * 1000), Math.max(0, maxMs - (Date.now() - started)));
|
|
1947
|
+
}
|
|
1948
|
+
continue; // transient — keep polling
|
|
1568
1949
|
}
|
|
1569
|
-
|
|
1950
|
+
// A closed milestone is terminal whether or not a verdict landed: waiting out the
|
|
1951
|
+
// loop on a row no verdict can move burns the whole budget and then says "poll again".
|
|
1952
|
+
if (!status.pending || nothingLeftToDecide(status)) {
|
|
1570
1953
|
return {
|
|
1571
1954
|
...status,
|
|
1572
1955
|
proofId,
|
|
@@ -1574,24 +1957,68 @@ async function handleWatchVerification({ milestoneId, content, filename }) {
|
|
|
1574
1957
|
};
|
|
1575
1958
|
}
|
|
1576
1959
|
// Parked for a human: `pending` stays true forever on this path, so without this
|
|
1577
|
-
// the loop burns its whole
|
|
1960
|
+
// the loop burns its whole budget and then tells the agent to poll some more.
|
|
1578
1961
|
if (parkedForHuman(status)) {
|
|
1579
1962
|
return { ...status, proofId, message: `Proof ${proofId}: ${PARKED_MESSAGE}` };
|
|
1580
1963
|
}
|
|
1964
|
+
if (status.claimPhase === "orphaned") {
|
|
1965
|
+
return {
|
|
1966
|
+
...status,
|
|
1967
|
+
proofId,
|
|
1968
|
+
message:
|
|
1969
|
+
`The last verification run for proof ${proofId} was interrupted and nothing is running now. ` +
|
|
1970
|
+
"Once its claim is an hour old, cascrow_verify can start it again — or submit the proof again, which always works. " +
|
|
1971
|
+
"(If cascrow_verify then refuses with \"cannot be restarted automatically\", submit the proof again or contact support.)",
|
|
1972
|
+
};
|
|
1973
|
+
}
|
|
1581
1974
|
}
|
|
1975
|
+
return null;
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
function stillRunningResult(proofId) {
|
|
1979
|
+
return {
|
|
1980
|
+
pending: true,
|
|
1981
|
+
proofId,
|
|
1982
|
+
message: `Verification is still in progress. Wait for it with cascrow_watch_verification { proofId: "${proofId}" } or poll cascrow_get_proof_status.`,
|
|
1983
|
+
};
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
async function handleWatchVerification({ milestoneId, content, filename, proofId: existingProofId } = {}) {
|
|
1987
|
+
// Two modes, exactly one per call: SUBMIT (milestoneId + content) uploads and then
|
|
1988
|
+
// waits; ATTACH (proofId) only waits — for a proof already submitted, whose
|
|
1989
|
+
// verification upload started by itself.
|
|
1990
|
+
const attach = typeof existingProofId === "string" && existingProofId.length > 0;
|
|
1991
|
+
const submit = typeof content === "string" && typeof milestoneId === "string";
|
|
1992
|
+
if (attach === submit) {
|
|
1993
|
+
throw new Error(
|
|
1994
|
+
"Give EITHER proofId (wait for a proof already submitted) OR milestoneId + content (submit, then wait) — not both, not neither."
|
|
1995
|
+
);
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
let proofId = existingProofId;
|
|
1999
|
+
if (submit) {
|
|
2000
|
+
const submitData = await uploadProof(milestoneId, filename ?? "proof-report.txt", content);
|
|
2001
|
+
proofId = submitData.proofId;
|
|
2002
|
+
if (!proofId) {
|
|
2003
|
+
throw new Error(submitData.error ?? "Proof submission failed — no proofId returned");
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
const settled = await pollUntilSettled(proofId, 100_000);
|
|
2008
|
+
if (settled) return settled;
|
|
1582
2009
|
|
|
1583
2010
|
// Timed out — return proofId so agent can poll manually
|
|
1584
2011
|
return {
|
|
1585
2012
|
pending: true,
|
|
1586
2013
|
proofId,
|
|
1587
|
-
message: `Verification still in progress after ~100s.
|
|
2014
|
+
message: `Verification still in progress after ~100s. Wait again with cascrow_watch_verification { proofId: "${proofId}" } or poll cascrow_get_proof_status.`,
|
|
1588
2015
|
};
|
|
1589
2016
|
}
|
|
1590
2017
|
|
|
1591
2018
|
// ─── MCP Server ───────────────────────────────────────────────────────────────
|
|
1592
2019
|
|
|
1593
2020
|
const server = new Server(
|
|
1594
|
-
{ name: "cascrow", version: "1.
|
|
2021
|
+
{ name: "cascrow", version: "1.8.0" },
|
|
1595
2022
|
{ capabilities: { tools: {} } }
|
|
1596
2023
|
);
|
|
1597
2024
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cascrow-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "MCP server 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",
|
package/server.json
CHANGED
|
@@ -9,13 +9,13 @@
|
|
|
9
9
|
"source": "github",
|
|
10
10
|
"subfolder": "cascrow-mcp"
|
|
11
11
|
},
|
|
12
|
-
"version": "1.
|
|
12
|
+
"version": "1.8.0",
|
|
13
13
|
"packages": [
|
|
14
14
|
{
|
|
15
15
|
"registryType": "npm",
|
|
16
16
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
17
17
|
"identifier": "cascrow-mcp",
|
|
18
|
-
"version": "1.
|
|
18
|
+
"version": "1.8.0",
|
|
19
19
|
"transport": {
|
|
20
20
|
"type": "stdio"
|
|
21
21
|
},
|