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 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 — auto-triggers AI verification. Returns `proofId`. |
126
- | `cascrow_verify` | Stream live 5-model AI verification results. |
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` | Submit proof and block until verification completes (combines submit + poll). |
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` → `cascrow_verify`
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` → `cascrow_verify`
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
- async function apiPost(path, body) {
47
- const res = await fetch(`${BASE_URL}${path}`, {
48
- method: "POST",
49
- headers: {
50
- ...authHeader,
51
- "Content-Type": "application/json",
52
- },
53
- body: JSON.stringify(body),
54
- });
55
- const data = await res.json();
56
- if (!res.ok) {
57
- throw new Error(data.error ?? `HTTP ${res.status}`);
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
- async function apiGet(path) {
63
- const res = await fetch(`${BASE_URL}${path}`, {
64
- headers: { ...authHeader },
65
- });
66
- const data = await res.json();
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
- throw new Error(data.error ?? `HTTP ${res.status}`);
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
- async function apiPatch(path, body) {
74
- const res = await fetch(`${BASE_URL}${path}`, {
75
- method: "PATCH",
76
- headers: {
77
- ...authHeader,
78
- "Content-Type": "application/json",
79
- },
80
- body: JSON.stringify(body),
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
- return data;
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 data = await res.json().catch(() => ({}));
157
- throw new Error(data.error ?? `HTTP ${res.status}`);
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 defines a deliverable, its acceptance criteria, and a deadline. " +
232
- "Returns contractId, milestoneIds, and a link to view the contract. " +
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: "Milestone title including acceptance criteria, e.g. 'Hero section with CTA — must be responsive down to 320px, LCP < 2s'",
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 automatically triggers AI verification. " +
307
- "You can call cascrow_verify afterwards to stream the live results.",
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
- "Stream live AI verification results for a submitted proof. " +
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
- "Call this after cascrow_submit_proof to get live results. " +
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
- "Submit a proof and wait for AI verification to complete — all in one call. " +
640
- "Combines cascrow_submit_proof + polling into a single blocking tool. " +
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
- "Times out after ~100 seconds and returns the proofId so you can poll manually with cascrow_get_proof_status.",
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: ["milestoneId", "content"],
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: "The milestone ID to submit proof for",
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
- title: m.title,
676
- amountUSD: m.amountUSD ?? 0,
677
- cancelAfter: deadline(m.deadlineDays),
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: `Proof submitted (proofId: ${data.proofId}). AI verification has been triggered automatically. Call cascrow_verify with this proofId to stream the live results.`,
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
- statusLine = `ℹ️ 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.`;
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
- * `pending` means `aiDecision === null`, which has two causes that read the same and
1308
- * are NOT the same thing: verification is still running, or the panel never reached a
1309
- * verdict and the proof was parked for human review with `aiDecision` deliberately
1310
- * left null. Told to keep polling, an agent polls FOREVER on the second one.
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 `aiDecision === null`, which has two causes that read the same and
1325
- // are NOT the same thing: verification is still running, or the panel never reached a
1326
- // verdict and the proof was parked for human review with `aiDecision` deliberately
1327
- // left null (`agent/proof-status` keeps it null by design there). Told to "poll again
1328
- // in a few seconds", an agent polls FOREVER on the second one. `milestoneStatus`
1329
- // separates them: a milestone already at PENDING_REVIEW is not waiting on a panel
1330
- // run, it is waiting on a person. `src/app/api/mcp/route.ts` has split these since
1331
- // SEC-010; this file, the published one, did not.
1332
- if (data.pending) {
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
- "ℹ️ This milestone was decided by another request while your proof was being verified — " +
1390
- "by the investor's review, or by a concurrent submission. Your evidence was recorded. " +
1391
- "Check the milestone status before resubmitting; a resubmission will be refused if it is already settled.",
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.json() };
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.json() };
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
- async function handleWatchVerification({ milestoneId, content, filename }) {
1549
- // Submit proof using existing multipart upload helper
1550
- const submitData = await uploadProof(milestoneId, filename ?? "proof-report.txt", content);
1551
- const proofId = submitData.proofId;
1552
- if (!proofId) {
1553
- throw new Error(submitData.error ?? "Proof submission failed — no proofId returned");
1554
- }
1555
-
1556
- // Poll until verification completes (max 100s, every 5s)
1557
- const MAX_WAIT_MS = 100_000;
1558
- const POLL_MS = 5_000;
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 < MAX_WAIT_MS) {
1562
- await new Promise((r) => setTimeout(r, POLL_MS));
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
- continue; // transient error — keep polling
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
- if (!status.pending) {
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 100s and then tells the agent to poll some more.
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. Poll the result with cascrow_get_proof_status using proofId: ${proofId}`,
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.6.2" },
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.7.0",
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.6.0",
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.6.0",
18
+ "version": "1.8.0",
19
19
  "transport": {
20
20
  "type": "stdio"
21
21
  },