cascrow-cli 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/README.md +24 -3
- package/builder-agent.js +104 -30
- package/index.js +314 -39
- package/package.json +1 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.4.0 — 2026-09-25
|
|
4
|
+
|
|
5
|
+
- New `cascrow status --proof <id> [--wait]` — show, or wait for, a proof's verification result. `submit` now points to it: verification starts by itself after an upload.
|
|
6
|
+
- `cascrow verify --proof <id>` on a proof whose verification is already running waits for that run instead of failing.
|
|
7
|
+
- `cascrow create` takes `--criteria`, `--criteria-file`, `--checklist-file` and `--review-mode` — the bar the AI panel judges against, kept out of the title (which is printed on the certificate).
|
|
8
|
+
- Every API call has a deadline and survives a non-JSON answer; errors end with the API's stable code in brackets, e.g. `[RATE_LIMITED]`.
|
|
9
|
+
- `status` never reports a released milestone as closed.
|
package/README.md
CHANGED
|
@@ -53,7 +53,8 @@ is never anchored.
|
|
|
53
53
|
| `create` | Create a milestone contract | Yes |
|
|
54
54
|
| `fund` | Activate a milestone (simulation — no on-chain tx) | Yes |
|
|
55
55
|
| `escrow-fund` | Fund a milestone with real on-chain RLUSD escrow | Yes |
|
|
56
|
-
| `submit` | Upload proof text
|
|
56
|
+
| `submit` | Upload proof text — verification starts by itself; returns `proofId` | Yes |
|
|
57
|
+
| `status` | Show, or `--wait` for, the verification result of a proof | Yes |
|
|
57
58
|
| `get` | Get contract status and milestones | Yes |
|
|
58
59
|
| `join` | Join a contract as Builder via invite code | Yes |
|
|
59
60
|
| `handoff` | Send a contract invite to a Builder agent by Agent ID | Yes |
|
|
@@ -97,11 +98,18 @@ Exactly one of `--pr`, `--code`, or `--image` is required alongside `--task`.
|
|
|
97
98
|
### `create`
|
|
98
99
|
|
|
99
100
|
```
|
|
100
|
-
--title <title> required milestone title
|
|
101
|
+
--title <title> required short milestone title (printed on the certificate)
|
|
101
102
|
--days <n> optional deadline days from now (default: 7)
|
|
102
103
|
--amount <usd> optional RLUSD to lock in escrow (default: 0 = verification-only)
|
|
104
|
+
--criteria <text> optional what the AI panel checks the proof against (max 4000 chars)
|
|
105
|
+
--criteria-file <path> optional the same, read from a file
|
|
106
|
+
--checklist-file <path> optional JSON array of { title, severity?, id? } (max 20), scored item by item on text proofs
|
|
107
|
+
--review-mode <mode> optional AUTO | MANUAL | MANUAL_AUTO — what happens when the panel is unsure (60–85%)
|
|
103
108
|
```
|
|
104
109
|
|
|
110
|
+
Keep acceptance criteria out of `--title`: the title is printed on the public
|
|
111
|
+
certificate, and the panel judges the proof against `--criteria`.
|
|
112
|
+
|
|
105
113
|
### `fund`
|
|
106
114
|
|
|
107
115
|
```
|
|
@@ -118,7 +126,7 @@ Exactly one of `--pr`, `--code`, or `--image` is required alongside `--task`.
|
|
|
118
126
|
--milestone <id> optional
|
|
119
127
|
```
|
|
120
128
|
|
|
121
|
-
### `submit` [
|
|
129
|
+
### `submit` [or use `verify` for text proof in one call]
|
|
122
130
|
|
|
123
131
|
```
|
|
124
132
|
--milestone <id> required
|
|
@@ -126,6 +134,19 @@ Exactly one of `--pr`, `--code`, or `--image` is required alongside `--task`.
|
|
|
126
134
|
--file <name> optional filename (default: proof-report.txt)
|
|
127
135
|
```
|
|
128
136
|
|
|
137
|
+
Verification starts by itself after the upload — wait for it with `status`, don't
|
|
138
|
+
start it again with `verify --proof` (that would only wait for the same run).
|
|
139
|
+
|
|
140
|
+
### `status`
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
--proof <id> required
|
|
144
|
+
--wait optional wait up to ~2 min; stops early on 401/403/404 or an interrupted run
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Errors end with the API's stable code in brackets, e.g. `[RATE_LIMITED]` — branch
|
|
148
|
+
on that, not on the wording.
|
|
149
|
+
|
|
129
150
|
### `get`
|
|
130
151
|
|
|
131
152
|
```
|
package/builder-agent.js
CHANGED
|
@@ -15,24 +15,57 @@ if (!API_KEY) {
|
|
|
15
15
|
process.exit(1);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
18
|
+
// Every call reads the body as text and parses it in a try (an HTML 502 used to
|
|
19
|
+
// crash the agent as "Unexpected token '<'"), carries a deadline over the body
|
|
20
|
+
// read, and throws an Error with `status` and `code` (T11-A4). Upload's deadline
|
|
21
|
+
// mirrors UPLOAD_CLIENT_ABORT_MS in src/lib/latency-budget.ts.
|
|
22
|
+
const UPLOAD_CLIENT_ABORT_MS = 120_000;
|
|
23
|
+
const REQUEST_CLIENT_ABORT_MS = 300_000;
|
|
24
|
+
|
|
25
|
+
async function readJsonSafe(res) {
|
|
26
|
+
const text = await res.text();
|
|
27
|
+
if (!text) return {};
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(text);
|
|
30
|
+
} catch {
|
|
31
|
+
return { error: `HTTP ${res.status} (non-JSON response from server)`, code: "UPSTREAM_NON_JSON" };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function fetchJson(method, path, { json, form, deadlineMs = REQUEST_CLIENT_ABORT_MS } = {}) {
|
|
36
|
+
const controller = new AbortController();
|
|
37
|
+
const timer = setTimeout(() => controller.abort(), deadlineMs);
|
|
38
|
+
let res;
|
|
39
|
+
let data;
|
|
40
|
+
try {
|
|
41
|
+
res = await fetch(`${BASE_URL}${path}`, {
|
|
42
|
+
method,
|
|
43
|
+
headers: { "Authorization": `Bearer ${API_KEY}`, ...(json !== undefined ? { "Content-Type": "application/json" } : {}) },
|
|
44
|
+
...(json !== undefined ? { body: JSON.stringify(json) } : form !== undefined ? { body: form } : {}),
|
|
45
|
+
signal: controller.signal,
|
|
46
|
+
});
|
|
47
|
+
data = await readJsonSafe(res);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
if (e && e.name === "AbortError") {
|
|
50
|
+
throw Object.assign(new Error(`No answer from ${path} within ${Math.round(deadlineMs / 1000)} s [CLIENT_TIMEOUT]`), { status: 0, code: "CLIENT_TIMEOUT" });
|
|
51
|
+
}
|
|
52
|
+
throw e;
|
|
53
|
+
} finally {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
}
|
|
56
|
+
if (!res.ok || data.code === "UPSTREAM_NON_JSON") {
|
|
57
|
+
const code = typeof data.code === "string" ? data.code : null;
|
|
58
|
+
throw Object.assign(new Error(`${data.error ?? `HTTP ${res.status}`}${code ? ` [${code}]` : ""}`), { status: res.status, code });
|
|
59
|
+
}
|
|
26
60
|
return data;
|
|
27
61
|
}
|
|
28
62
|
|
|
63
|
+
async function apiPost(path, body) {
|
|
64
|
+
return fetchJson("POST", path, { json: body });
|
|
65
|
+
}
|
|
66
|
+
|
|
29
67
|
async function apiGet(path) {
|
|
30
|
-
|
|
31
|
-
headers: { "Authorization": `Bearer ${API_KEY}` },
|
|
32
|
-
});
|
|
33
|
-
const data = await res.json();
|
|
34
|
-
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
35
|
-
return data;
|
|
68
|
+
return fetchJson("GET", path);
|
|
36
69
|
}
|
|
37
70
|
|
|
38
71
|
async function uploadProof(milestoneId, content) {
|
|
@@ -40,14 +73,7 @@ async function uploadProof(milestoneId, content) {
|
|
|
40
73
|
const form = new FormData();
|
|
41
74
|
form.append("file", blob, "proof-report.txt");
|
|
42
75
|
form.append("milestoneId", milestoneId);
|
|
43
|
-
|
|
44
|
-
method: "POST",
|
|
45
|
-
headers: { "Authorization": `Bearer ${API_KEY}` },
|
|
46
|
-
body: form,
|
|
47
|
-
});
|
|
48
|
-
const data = await res.json();
|
|
49
|
-
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
50
|
-
return data;
|
|
76
|
+
return fetchJson("POST", "/api/proof/upload", { form, deadlineMs: UPLOAD_CLIENT_ABORT_MS });
|
|
51
77
|
}
|
|
52
78
|
|
|
53
79
|
// SEC-073. The FIFTH client door of `/api/verify`, and until 2026-09-22 the only
|
|
@@ -93,8 +119,16 @@ async function streamVerify(proofId) {
|
|
|
93
119
|
}
|
|
94
120
|
if (!res.ok) {
|
|
95
121
|
clearTimeout(timeout);
|
|
96
|
-
const data = await res
|
|
97
|
-
|
|
122
|
+
const data = await readJsonSafe(res);
|
|
123
|
+
// T01-8. Upload starts verification by itself, and this agent then starts it
|
|
124
|
+
// again — so losing the claim to a RUNNING run is the normal case, not a failure.
|
|
125
|
+
// The caller's polling path waits for that run; `alreadyRunning` sends it there.
|
|
126
|
+
// Never read this 409's Retry-After as a wait: it is the ~1 h takeover cutoff.
|
|
127
|
+
if (res.status === 409 && data.code === "VERIFICATION_IN_PROGRESS" && data.phase === "running") {
|
|
128
|
+
throw Object.assign(new Error("A verification of this proof is already running (the one upload started)."), { alreadyRunning: true });
|
|
129
|
+
}
|
|
130
|
+
const code = typeof data.code === "string" ? data.code : null;
|
|
131
|
+
throw Object.assign(new Error(`${data.error ?? `HTTP ${res.status}`}${code ? ` [${code}]` : ""}`), { status: res.status, code });
|
|
98
132
|
}
|
|
99
133
|
if (!res.body) {
|
|
100
134
|
clearTimeout(timeout);
|
|
@@ -224,7 +258,15 @@ VERIFICATION CRITERIA MET:
|
|
|
224
258
|
// Stream disconnected before complete event (e.g. Vercel 150s timeout).
|
|
225
259
|
// The verification is still running server-side — just poll for the result,
|
|
226
260
|
// do NOT call streamVerify again (would trigger duplicate AI verification).
|
|
227
|
-
|
|
261
|
+
// Two ways here, and the loop below serves both: the stream dropped mid-run, or
|
|
262
|
+
// it never opened because upload's own run already held the claim (T01-8).
|
|
263
|
+
if (streamErr && streamErr.alreadyRunning) {
|
|
264
|
+
console.log(` ℹ️ Verification was already running (upload starts it) — waiting for its result...`);
|
|
265
|
+
} else if (streamErr && (streamErr.status === 401 || streamErr.status === 403 || streamErr.status === 404)) {
|
|
266
|
+
throw streamErr;
|
|
267
|
+
} else {
|
|
268
|
+
console.log(` ⚠️ Stream ended early, polling for result...`);
|
|
269
|
+
}
|
|
228
270
|
// SEC-076. This polled `contract.status`, and that column answers a different
|
|
229
271
|
// question than the one asked here. It is a single slot with nine writers:
|
|
230
272
|
// funding a SIBLING milestone writes `contractStatusAfterFunding(...)` over
|
|
@@ -240,13 +282,33 @@ VERIFICATION CRITERIA MET:
|
|
|
240
282
|
// funded a specific one, so the honest read is that row. The contract-level
|
|
241
283
|
// read is kept only for the legacy population that has no milestone rows at
|
|
242
284
|
// all (proofs written via the contractId-only path).
|
|
243
|
-
|
|
285
|
+
//
|
|
286
|
+
// EXPIRED is terminal too. Since 2026-09-23 `/api/verify` no longer reopens a
|
|
287
|
+
// milestone that went EXPIRED while its panel ran, so without it this loop polled a
|
|
288
|
+
// closed row for five minutes and then threw "Timed out" — and it must never be
|
|
289
|
+
// mapped to a YES: the milestone moved no funds through this verification.
|
|
290
|
+
const TERMINAL_PROOF_STATES = ["VERIFIED", "COMPLETED", "REJECTED", "PENDING_REVIEW", "EXPIRED"];
|
|
244
291
|
for (let i = 0; i < 60; i++) {
|
|
245
292
|
await new Promise((r) => setTimeout(r, 5000));
|
|
246
|
-
|
|
293
|
+
let contract;
|
|
294
|
+
try {
|
|
295
|
+
contract = await apiGet(`/api/contracts/${joined.contractId}`);
|
|
296
|
+
} catch (e) {
|
|
297
|
+
// 401/403/404 do not change by waiting; anything else is transient. One
|
|
298
|
+
// dropped poll used to end the whole run.
|
|
299
|
+
if (e && (e.status === 401 || e.status === 403 || e.status === 404)) throw e;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
247
302
|
const mine = (contract.milestones ?? []).find((m) => m.id === fundedMilestoneId);
|
|
248
303
|
const observed = mine ? mine.status : (contract.milestones ?? []).length === 0 ? contract.status : null;
|
|
249
304
|
if (observed && TERMINAL_PROOF_STATES.includes(observed)) {
|
|
305
|
+
if (observed === "EXPIRED") {
|
|
306
|
+
// No verdict is read on this path: the stream that carried it dropped, and
|
|
307
|
+
// the row says only that it is closed.
|
|
308
|
+
result = { decision: null, confidence: 0, action: "EXPIRED", milestoneClosed: true };
|
|
309
|
+
console.log(` ✓ Got result via polling: ${observed}`);
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
250
312
|
const decision = ["VERIFIED", "COMPLETED"].includes(observed) ? "YES" : "NO";
|
|
251
313
|
result = { decision, confidence: 0, action: observed };
|
|
252
314
|
console.log(` ✓ Got result via polling: ${observed}`);
|
|
@@ -269,8 +331,20 @@ VERIFICATION CRITERIA MET:
|
|
|
269
331
|
const released = result.action === "COMPLETED";
|
|
270
332
|
const passed = released;
|
|
271
333
|
console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
|
|
272
|
-
if (result.action === "
|
|
273
|
-
|
|
334
|
+
if (result.milestoneClosed === true && result.action === "EXPIRED") {
|
|
335
|
+
// `GET /api/contracts/{id}` does not return the milestone's `terminalReason`, so this
|
|
336
|
+
// cannot say whether the funder was refunded or the builder was paid on-chain, and
|
|
337
|
+
// does not guess.
|
|
338
|
+
console.log(
|
|
339
|
+
`⛔ CLOSED — the milestone was closed (EXPIRED) while the panel ran or after it decided, so this verification moved no funds. ` +
|
|
340
|
+
`The reason is not available here — check the contract page before assuming where the funds are. Do not resubmit to it.`
|
|
341
|
+
);
|
|
342
|
+
} else if (result.action === "SUPERSEDED") {
|
|
343
|
+
console.log(
|
|
344
|
+
result.settledAs
|
|
345
|
+
? `ℹ️ SUPERSEDED — this milestone was closed (${result.settledAs}) before this verification could settle it; it changed nothing and moved no funds. Do not resubmit to it.`
|
|
346
|
+
: `ℹ️ SUPERSEDED — another proof on this milestone was decided first; this changed nothing.`
|
|
347
|
+
);
|
|
274
348
|
} else if (released) {
|
|
275
349
|
console.log(
|
|
276
350
|
result.alreadyReleased === true
|
|
@@ -292,7 +366,7 @@ VERIFICATION CRITERIA MET:
|
|
|
292
366
|
} else {
|
|
293
367
|
console.log(`⚠️ Outcome not recognised by this version (action: ${result.action}). No conclusion drawn.`);
|
|
294
368
|
}
|
|
295
|
-
console.log(` Decision: ${result.decision}`);
|
|
369
|
+
console.log(` Decision: ${result.decision ?? "none read (the stream dropped before it)"}`);
|
|
296
370
|
console.log(` View contract: ${BASE_URL}/contract/${invite.contractId}`);
|
|
297
371
|
console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
|
|
298
372
|
}
|
package/index.js
CHANGED
|
@@ -19,20 +19,86 @@ function authHeader(key) {
|
|
|
19
19
|
return key ? { "Authorization": `Bearer ${key}` } : {};
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
22
|
+
// Client deadlines (T11-A4). This package cannot import src/, so the one with an
|
|
23
|
+
// owner is MIRRORED and checked against it by client-deadline-mirrors.test.ts:
|
|
24
|
+
// UPLOAD_CLIENT_ABORT_MS mirrors UPLOAD_CLIENT_ABORT_MS in src/lib/latency-budget.ts
|
|
25
|
+
// mcp/submit is 235 s like the stream (one step below its 240 s cap); everything
|
|
26
|
+
// else gets the longest maxDuration any route has. None of these calls had a
|
|
27
|
+
// deadline before.
|
|
28
|
+
const UPLOAD_CLIENT_ABORT_MS = 120_000;
|
|
29
|
+
const MCP_SUBMIT_CLIENT_ABORT_MS = 235_000;
|
|
30
|
+
const REQUEST_CLIENT_ABORT_MS = 300_000;
|
|
31
|
+
|
|
32
|
+
/** A response body parsed without trusting it to be JSON: an HTML 502 reads "HTTP 502 (non-JSON response from server)". */
|
|
33
|
+
async function readJsonSafe(res) {
|
|
34
|
+
const text = await res.text();
|
|
35
|
+
if (!text) return {};
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(text);
|
|
38
|
+
} catch {
|
|
39
|
+
return { error: `HTTP ${res.status} (non-JSON response from server)`, code: "UPSTREAM_NON_JSON" };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Seconds the server asked us to wait: Retry-After, else `retry_after_seconds`. */
|
|
44
|
+
function retryAfterOf(res, data) {
|
|
45
|
+
const header = res.headers && typeof res.headers.get === "function" ? res.headers.get("retry-after") : null;
|
|
46
|
+
if (header !== null && /^\d+$/.test(header.trim())) return Number(header.trim());
|
|
47
|
+
return typeof data?.retry_after_seconds === "number" ? data.retry_after_seconds : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The one way this CLI calls the API. Throws an Error carrying `status`, `code` and
|
|
52
|
+
* `retryAfterSeconds`; its message ends with the code in brackets, so a script that
|
|
53
|
+
* greps stderr can branch on it. The deadline covers the body read.
|
|
54
|
+
*/
|
|
55
|
+
async function fetchJson(method, path, { json, form, key = API_KEY, deadlineMs = REQUEST_CLIENT_ABORT_MS } = {}) {
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
const timer = setTimeout(() => controller.abort(), deadlineMs);
|
|
58
|
+
let res;
|
|
59
|
+
let data;
|
|
60
|
+
try {
|
|
61
|
+
res = await fetch(`${BASE_URL}${path}`, {
|
|
62
|
+
method,
|
|
63
|
+
headers: { ...authHeader(key), ...(json !== undefined ? { "Content-Type": "application/json" } : {}) },
|
|
64
|
+
...(json !== undefined ? { body: JSON.stringify(json) } : form !== undefined ? { body: form } : {}),
|
|
65
|
+
signal: controller.signal,
|
|
66
|
+
});
|
|
67
|
+
data = await readJsonSafe(res);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
if (e && e.name === "AbortError") {
|
|
70
|
+
throw Object.assign(
|
|
71
|
+
new Error(`No answer from ${path} within ${Math.round(deadlineMs / 1000)} s. The request may still complete on the server — check its result before repeating it. [CLIENT_TIMEOUT]`),
|
|
72
|
+
{ status: 0, code: "CLIENT_TIMEOUT", retryAfterSeconds: null }
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
throw e;
|
|
76
|
+
} finally {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
}
|
|
79
|
+
if (!res.ok || data.code === "UPSTREAM_NON_JSON") {
|
|
80
|
+
const code = typeof data.code === "string" ? data.code : null;
|
|
81
|
+
const retryAfterSeconds = retryAfterOf(res, data);
|
|
82
|
+
const base = typeof data.error === "string" && data.error ? data.error : `HTTP ${res.status}`;
|
|
83
|
+
// A platform error page on a POST (typically the 504 when a route hits its own
|
|
84
|
+
// maxDuration — escrow-fund's is 120 s) does not mean the call failed: the function
|
|
85
|
+
// may have moved money before it was killed. Say so, and how to check.
|
|
86
|
+
const mayHaveCompleted = method !== "GET" && res.status >= 500 && data.code === "UPSTREAM_NON_JSON";
|
|
87
|
+
throw Object.assign(
|
|
88
|
+
new Error(
|
|
89
|
+
`${base}${retryAfterSeconds !== null && (res.status === 429 || res.status === 503) ? ` — retry after ${retryAfterSeconds} s` : ""}${code ? ` [${code}]` : ""}` +
|
|
90
|
+
(mayHaveCompleted ? ". The request may still have completed on the server — check the contract (cascrow get --contract <id>) before repeating it." : "")
|
|
91
|
+
),
|
|
92
|
+
{ status: res.status, code, retryAfterSeconds }
|
|
93
|
+
);
|
|
94
|
+
}
|
|
33
95
|
return data;
|
|
34
96
|
}
|
|
35
97
|
|
|
98
|
+
async function apiPost(path, body, key = API_KEY, deadlineMs = REQUEST_CLIENT_ABORT_MS) {
|
|
99
|
+
return fetchJson("POST", path, { json: body, key, deadlineMs });
|
|
100
|
+
}
|
|
101
|
+
|
|
36
102
|
// SEC-078. A failed standalone verification still has a row, and the route answers
|
|
37
103
|
// the 500 with `id`, `publicHash` and `reportUrl` so the caller who paid for it can name it. The
|
|
38
104
|
// CLI only prints a string, so the reference goes into the string. Both request
|
|
@@ -46,12 +112,7 @@ function standaloneFailure(status, data) {
|
|
|
46
112
|
}
|
|
47
113
|
|
|
48
114
|
async function apiGet(path, key = API_KEY) {
|
|
49
|
-
|
|
50
|
-
headers: { ...authHeader(key) },
|
|
51
|
-
});
|
|
52
|
-
const data = await res.json();
|
|
53
|
-
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
54
|
-
return data;
|
|
115
|
+
return fetchJson("GET", path, { key });
|
|
55
116
|
}
|
|
56
117
|
|
|
57
118
|
async function uploadProof(milestoneId, filename, content, key = API_KEY) {
|
|
@@ -59,14 +120,7 @@ async function uploadProof(milestoneId, filename, content, key = API_KEY) {
|
|
|
59
120
|
const form = new FormData();
|
|
60
121
|
form.append("file", blob, filename);
|
|
61
122
|
form.append("milestoneId", milestoneId);
|
|
62
|
-
|
|
63
|
-
method: "POST",
|
|
64
|
-
headers: { ...authHeader(key) },
|
|
65
|
-
body: form,
|
|
66
|
-
});
|
|
67
|
-
const data = await res.json();
|
|
68
|
-
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
69
|
-
return data;
|
|
123
|
+
return fetchJson("POST", "/api/proof/upload", { form, key, deadlineMs: UPLOAD_CLIENT_ABORT_MS });
|
|
70
124
|
}
|
|
71
125
|
|
|
72
126
|
// Stream verify via SSE — used by the legacy two-step `submit` + `verify --proof <id>` flow
|
|
@@ -112,8 +166,21 @@ async function streamVerify(proofId, key = API_KEY) {
|
|
|
112
166
|
// long-lived MCP server that holds the event loop and later aborts a fetch
|
|
113
167
|
// that finished minutes ago.
|
|
114
168
|
clearTimeout(timeout);
|
|
115
|
-
const data = await res
|
|
116
|
-
|
|
169
|
+
const data = await readJsonSafe(res);
|
|
170
|
+
// T01-8. Upload starts verification by itself, so losing the claim to a RUNNING
|
|
171
|
+
// run is the normal case: wait for that run through proof-status instead of
|
|
172
|
+
// failing. This 409's Retry-After is the ~1 h takeover cutoff, never a wait.
|
|
173
|
+
if (res.status === 409 && data.code === "VERIFICATION_IN_PROGRESS" && data.phase === "running") {
|
|
174
|
+
process.stderr.write(" A verification of this proof is already running — waiting for it instead of starting another.\n");
|
|
175
|
+
const budgetMs = Math.max(30_000, Math.min(235_000, ((data.expected_done_in_seconds ?? 200) + 30) * 1000));
|
|
176
|
+
return { attached: true, status: await waitForProof(proofId, budgetMs, key) };
|
|
177
|
+
}
|
|
178
|
+
const code = typeof data.code === "string" ? data.code : null;
|
|
179
|
+
throw Object.assign(new Error(`${data.error ?? `HTTP ${res.status}`}${code ? ` [${code}]` : ""}`), {
|
|
180
|
+
status: res.status,
|
|
181
|
+
code,
|
|
182
|
+
retryAfterSeconds: retryAfterOf(res, data),
|
|
183
|
+
});
|
|
117
184
|
}
|
|
118
185
|
if (!res.body) {
|
|
119
186
|
clearTimeout(timeout);
|
|
@@ -178,6 +245,73 @@ async function streamVerify(proofId, key = API_KEY) {
|
|
|
178
245
|
throw new Error("Stream ended without a complete event");
|
|
179
246
|
}
|
|
180
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Poll proof-status until the proof settles, the run is found dead, or `maxMs` runs
|
|
250
|
+
* out. Returns the last status read, with `stoppedBecause` set when it stopped early:
|
|
251
|
+
* - 401 / 403: waiting does not change the key or the account;
|
|
252
|
+
* - 404: permanent — upload answers only after the proof exists and proof-status
|
|
253
|
+
* reads the primary database, so the proof was deleted or erased;
|
|
254
|
+
* - "interrupted": the run that held the proof died (claimPhase "orphaned").
|
|
255
|
+
* 429 / 503 wait at least Retry-After, never past the budget; anything else is
|
|
256
|
+
* transient. A milestone that is closed, or parked for a human, also settles it.
|
|
257
|
+
*/
|
|
258
|
+
async function waitForProof(proofId, maxMs = 100_000, key = API_KEY, pollMs = 5_000) {
|
|
259
|
+
const started = Date.now();
|
|
260
|
+
let waitMs = pollMs;
|
|
261
|
+
let last = null;
|
|
262
|
+
while (Date.now() - started < maxMs) {
|
|
263
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
264
|
+
waitMs = pollMs;
|
|
265
|
+
try {
|
|
266
|
+
last = await apiGet(`/api/agent/proof-status/${encodeURIComponent(proofId)}`, key);
|
|
267
|
+
} catch (e) {
|
|
268
|
+
if (e && (e.status === 401 || e.status === 403 || e.status === 404)) {
|
|
269
|
+
return { pending: true, proofId, stoppedBecause: e.status, error: e.message };
|
|
270
|
+
}
|
|
271
|
+
if (e && (e.status === 429 || e.status === 503) && typeof e.retryAfterSeconds === "number") {
|
|
272
|
+
waitMs = Math.min(Math.max(pollMs, e.retryAfterSeconds * 1000), Math.max(0, maxMs - (Date.now() - started)));
|
|
273
|
+
}
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (!last.pending || last.milestoneClosed === true) return { ...last, proofId };
|
|
277
|
+
// Parked for a human: no poll will change it (same rule as both MCP servers).
|
|
278
|
+
if (last.running !== true && last.milestoneStatus === "PENDING_REVIEW") return { ...last, proofId, awaitingHumanReview: true };
|
|
279
|
+
if (last.claimPhase === "orphaned") return { ...last, proofId, stoppedBecause: "interrupted" };
|
|
280
|
+
}
|
|
281
|
+
return { ...(last ?? {}), pending: true, proofId, timedOut: true };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** One line for a proof-status result, saying whether funds moved (they did only at COMPLETED). */
|
|
285
|
+
function statusSummary(s) {
|
|
286
|
+
if (s.stoppedBecause === 404) return `⛔ Proof ${s.proofId} no longer exists (deleted or erased). Nothing will verify it.`;
|
|
287
|
+
if (s.stoppedBecause === 401 || s.stoppedBecause === 403) return `⛔ Stopped: ${s.error}. Waiting does not change this — check CASCROW_API_KEY and that this account is a party to the contract.`;
|
|
288
|
+
if (s.stoppedBecause === "interrupted") {
|
|
289
|
+
// Not "re-run automatically": a claim with no timestamp is never taken over, and
|
|
290
|
+
// the retry cron skips demo contracts and milestones past PROOF_SUBMITTED.
|
|
291
|
+
return `⚠️ The last verification run was interrupted; nothing is running now. Once its claim is an hour old, \`cascrow verify --proof ${s.proofId}\` can start it again — or submit the proof again, which always works.`;
|
|
292
|
+
}
|
|
293
|
+
if (s.awaitingHumanReview) return "⏳ PENDING_REVIEW — the panel reached no verdict; a human decides. Waiting will not change it.";
|
|
294
|
+
if (s.timedOut) return `⏳ Still verifying. Check again: cascrow status --proof ${s.proofId} --wait`;
|
|
295
|
+
const conf = typeof s.confidence === "number" ? ` (${s.confidence}% confidence)` : "";
|
|
296
|
+
// COMPLETED first: proof-status reports a paid milestone as `milestoneClosed` too
|
|
297
|
+
// (terminalStatus COMPLETED), and the CLOSED arm below would read a release as
|
|
298
|
+
// "moves no funds" — SEC-059's inversion (same order as proofOutcomeLine in both
|
|
299
|
+
// MCP servers). Whether it was THIS proof's YES is `decision`.
|
|
300
|
+
if (s.milestoneStatus === "COMPLETED" || s.terminalStatus === "COMPLETED") {
|
|
301
|
+
return s.decision === "YES" ? `✅ COMPLETED${conf} — funds released.` : "✅ COMPLETED — funds released, but not on this proof's verdict (another proof or the funder's review approved it).";
|
|
302
|
+
}
|
|
303
|
+
if (s.milestoneClosed === true) {
|
|
304
|
+
return s.terminalReason === "RELEASED_ON_CHAIN"
|
|
305
|
+
? "⛔ CLOSED (EXPIRED) — the chain shows the builder WAS paid, just not by this verification."
|
|
306
|
+
: `⛔ CLOSED — the milestone is ${s.terminalStatus ?? "closed"}; this verification moved no funds. Do not resubmit to it.`;
|
|
307
|
+
}
|
|
308
|
+
if (s.milestoneStatus === "VERIFIED" && s.releaseHoldUntil) return `⏸️ VERIFIED${conf} — payout scheduled for ${s.releaseHoldUntil}. NO FUNDS HAVE MOVED YET.`;
|
|
309
|
+
if (s.milestoneStatus === "VERIFIED") return `⚠️ VERIFIED${conf} — approved, no funds moved yet.`;
|
|
310
|
+
if (s.milestoneStatus === "PENDING_REVIEW") return `⏳ PENDING_REVIEW${conf} — manual review required.`;
|
|
311
|
+
if (s.decision === "NO") return `❌ REJECTED${conf}.`;
|
|
312
|
+
return `ℹ️ Decision: ${s.decision ?? "none"}${conf}, milestone ${s.milestoneStatus ?? "unknown"}.`;
|
|
313
|
+
}
|
|
314
|
+
|
|
181
315
|
/**
|
|
182
316
|
* One human-readable line for a verification `complete` frame.
|
|
183
317
|
*
|
|
@@ -189,7 +323,11 @@ async function streamVerify(proofId, key = API_KEY) {
|
|
|
189
323
|
function outcomeLine(r) {
|
|
190
324
|
const c = r.confidence ?? 0;
|
|
191
325
|
if (r.action === "SUPERSEDED") {
|
|
192
|
-
|
|
326
|
+
// `settledAs`: the milestone was closed (e.g. EXPIRED) before this verification could
|
|
327
|
+
// settle it, not beaten by a proof.
|
|
328
|
+
return r.settledAs
|
|
329
|
+
? `ℹ️ SUPERSEDED — this milestone was closed (${r.settledAs}) before this verification could settle it, so it changed nothing and moved no funds. Do not resubmit to it.`
|
|
330
|
+
: "ℹ️ SUPERSEDED — another proof on this milestone was decided first, so this verification changed nothing. Your evidence was recorded.";
|
|
193
331
|
}
|
|
194
332
|
if (r.action === "COMPLETED") {
|
|
195
333
|
return r.alreadyReleased === true
|
|
@@ -297,7 +435,8 @@ Commands:
|
|
|
297
435
|
create Create a milestone contract
|
|
298
436
|
fund Activate a milestone (simulation — no on-chain tx)
|
|
299
437
|
escrow-fund Fund a milestone with real on-chain RLUSD escrow
|
|
300
|
-
submit Upload proof text (
|
|
438
|
+
submit Upload proof text — verification starts by itself (then: status --proof <id> --wait)
|
|
439
|
+
status Show (or --wait for) the verification result of a proof
|
|
301
440
|
get Get contract status and milestones
|
|
302
441
|
join Join a contract as Builder via invite code
|
|
303
442
|
handoff Send a contract invite to a Builder agent by Agent ID
|
|
@@ -328,9 +467,13 @@ Options for each command:
|
|
|
328
467
|
--commit <sha> optional Git commit SHA or PR URL
|
|
329
468
|
|
|
330
469
|
create
|
|
331
|
-
--title <title> required milestone title
|
|
470
|
+
--title <title> required short milestone title (printed on the certificate)
|
|
332
471
|
--days <n> optional deadline days from now (default: 7)
|
|
333
472
|
--amount <usd> optional RLUSD to lock in escrow (default: 0 = verification-only)
|
|
473
|
+
--criteria <text> optional what the AI panel checks the proof against (max 4000 chars)
|
|
474
|
+
--criteria-file <path> optional the same, read from a file
|
|
475
|
+
--checklist-file <path> optional JSON array of { title, severity?, id? } (max 20) scored item by item on text proofs
|
|
476
|
+
--review-mode <mode> optional AUTO | MANUAL | MANUAL_AUTO — what happens when the panel is unsure (60–85%)
|
|
334
477
|
|
|
335
478
|
fund
|
|
336
479
|
--contract <id> required
|
|
@@ -342,11 +485,15 @@ Options for each command:
|
|
|
342
485
|
--amount <usd> IGNORED the escrowed amount is the milestone's own amountUSD
|
|
343
486
|
--milestone <id> optional
|
|
344
487
|
|
|
345
|
-
submit [
|
|
488
|
+
submit [or use verify for text proof in one call]
|
|
346
489
|
--milestone <id> required
|
|
347
490
|
--proof <text> required
|
|
348
491
|
--file <name> optional filename (default: proof-report.txt)
|
|
349
492
|
|
|
493
|
+
status
|
|
494
|
+
--proof <id> required
|
|
495
|
+
--wait optional wait up to ~2 min for the verdict; stops early when waiting cannot help
|
|
496
|
+
|
|
350
497
|
get
|
|
351
498
|
--contract <id> required
|
|
352
499
|
|
|
@@ -376,7 +523,7 @@ Examples:
|
|
|
376
523
|
cascrow register --email bot@example.com --password secret123
|
|
377
524
|
|
|
378
525
|
# One-shot: create contract, fund it, then verify a code fix
|
|
379
|
-
cascrow create --title "Fix auth bug
|
|
526
|
+
cascrow create --title "Fix auth bug" --criteria "All tests pass; JWT expiry honoured; no new lint errors" --days 7
|
|
380
527
|
cascrow fund --contract <contractId>
|
|
381
528
|
cascrow verify --contract <contractId> --proof "Fixed JWT expiry bug in auth.ts, 42 tests green, PR #51 merged" --commit abc1234 --links https://github.com/you/repo/pull/51
|
|
382
529
|
|
|
@@ -403,6 +550,65 @@ Examples:
|
|
|
403
550
|
process.exit(0);
|
|
404
551
|
}
|
|
405
552
|
|
|
553
|
+
// ─── The bar a milestone is judged against ────────────────────────────────────
|
|
554
|
+
// COPY of normalizeMilestoneChecklist / normalizeCriteria in src/lib/milestone-bar-input.ts
|
|
555
|
+
// (this package cannot import src/). milestone-bar-input-parity.test.ts runs this copy
|
|
556
|
+
// against the owner over the same case matrix.
|
|
557
|
+
const CHECKLIST_MAX_ITEMS = 20;
|
|
558
|
+
const CHECKLIST_TITLE_MAX = 200;
|
|
559
|
+
const CHECKLIST_ID_MAX = 100;
|
|
560
|
+
const CRITERIA_MAX = 4000;
|
|
561
|
+
const CHECKLIST_SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW"];
|
|
562
|
+
|
|
563
|
+
function normalizeMilestoneChecklist(items) {
|
|
564
|
+
if (items === undefined || items === null) return undefined;
|
|
565
|
+
if (!Array.isArray(items)) throw new Error("verificationChecklist: must be an array of { title, severity?, id? }");
|
|
566
|
+
if (items.length === 0) return undefined;
|
|
567
|
+
if (items.length > CHECKLIST_MAX_ITEMS) throw new Error(`verificationChecklist: at most ${CHECKLIST_MAX_ITEMS} items`);
|
|
568
|
+
items.forEach((raw, i) => {
|
|
569
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
570
|
+
throw new Error(`verificationChecklist[${i}]: must be an object with a title`);
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
const withId = items.filter((it) => it.id !== undefined).length;
|
|
574
|
+
if (withId !== 0 && withId !== items.length) {
|
|
575
|
+
throw new Error("verificationChecklist: either give every item an id or none (ids are then assigned 1..n)");
|
|
576
|
+
}
|
|
577
|
+
const seen = new Set();
|
|
578
|
+
return items.map((it, i) => {
|
|
579
|
+
const title = typeof it.title === "string" ? it.title.trim() : "";
|
|
580
|
+
if (title.length === 0) throw new Error(`verificationChecklist[${i}]: title is required`);
|
|
581
|
+
if (title.length > CHECKLIST_TITLE_MAX) throw new Error(`verificationChecklist[${i}]: title must be at most ${CHECKLIST_TITLE_MAX} characters`);
|
|
582
|
+
let id = i + 1;
|
|
583
|
+
if (withId) {
|
|
584
|
+
if (typeof it.id !== "number" || !Number.isInteger(it.id) || it.id < 1 || it.id > CHECKLIST_ID_MAX) {
|
|
585
|
+
throw new Error(`verificationChecklist[${i}]: id must be an integer from 1 to ${CHECKLIST_ID_MAX}`);
|
|
586
|
+
}
|
|
587
|
+
if (seen.has(it.id)) throw new Error(`verificationChecklist[${i}]: duplicate id ${it.id}`);
|
|
588
|
+
seen.add(it.id);
|
|
589
|
+
id = it.id;
|
|
590
|
+
}
|
|
591
|
+
const out = { id, title };
|
|
592
|
+
if (it.severity !== undefined && it.severity !== null && it.severity !== "") {
|
|
593
|
+
const sev = typeof it.severity === "string" ? it.severity.trim().toUpperCase() : "";
|
|
594
|
+
if (!CHECKLIST_SEVERITIES.includes(sev)) {
|
|
595
|
+
throw new Error(`verificationChecklist[${i}]: severity must be one of ${CHECKLIST_SEVERITIES.join(", ")}`);
|
|
596
|
+
}
|
|
597
|
+
out.severity = sev;
|
|
598
|
+
}
|
|
599
|
+
return out;
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function normalizeCriteria(value) {
|
|
604
|
+
if (value === undefined || value === null) return undefined;
|
|
605
|
+
if (typeof value !== "string") throw new Error("verificationCriteria: must be a string");
|
|
606
|
+
const v = value.trim();
|
|
607
|
+
if (v.length === 0) return undefined;
|
|
608
|
+
if (v.length > CRITERIA_MAX) throw new Error(`verificationCriteria: at most ${CRITERIA_MAX} characters (got ${v.length})`);
|
|
609
|
+
return v;
|
|
610
|
+
}
|
|
611
|
+
|
|
406
612
|
// ─── Argument parser ──────────────────────────────────────────────────────────
|
|
407
613
|
|
|
408
614
|
function parseArgs(argv) {
|
|
@@ -439,8 +645,8 @@ async function cmdRegister(args) {
|
|
|
439
645
|
keyName: args["key-name"] ?? "cli",
|
|
440
646
|
}),
|
|
441
647
|
});
|
|
442
|
-
const data = await res
|
|
443
|
-
if (!res.ok) err(data.error ?? `HTTP ${res.status}`);
|
|
648
|
+
const data = await readJsonSafe(res);
|
|
649
|
+
if (!res.ok || data.code === "UPSTREAM_NON_JSON") err(`${data.error ?? `HTTP ${res.status}`}${data.code ? ` [${data.code}]` : ""}`);
|
|
444
650
|
print(data);
|
|
445
651
|
console.error("\nSave your API key and set it:\n export CASCROW_API_KEY=" + data.apiKey + "\n");
|
|
446
652
|
}
|
|
@@ -484,7 +690,7 @@ async function cmdVerifyWork(args) {
|
|
|
484
690
|
// The body is read INSIDE the deadline. A response whose headers arrived
|
|
485
691
|
// and whose body then stalls is otherwise unbounded — a deadline that ends
|
|
486
692
|
// at the headers bounds time-to-first-byte and nothing else (SEC-073).
|
|
487
|
-
return { res: r, data: await r
|
|
693
|
+
return { res: r, data: await readJsonSafe(r) };
|
|
488
694
|
});
|
|
489
695
|
if (!res.ok) throw standaloneFailure(res.status, body);
|
|
490
696
|
// Assigned to the OUTER `data`, not shadowing it: `const { data }` inside the
|
|
@@ -506,7 +712,7 @@ async function cmdVerifyWork(args) {
|
|
|
506
712
|
// The body is read INSIDE the deadline. A response whose headers arrived
|
|
507
713
|
// and whose body then stalls is otherwise unbounded — a deadline that ends
|
|
508
714
|
// at the headers bounds time-to-first-byte and nothing else (SEC-073).
|
|
509
|
-
return { res: r, data: await r
|
|
715
|
+
return { res: r, data: await readJsonSafe(r) };
|
|
510
716
|
});
|
|
511
717
|
if (!res.ok) throw standaloneFailure(res.status, answer);
|
|
512
718
|
// Assigned to the OUTER `data`, not shadowing it: `const { data }` inside the
|
|
@@ -554,6 +760,13 @@ async function cmdVerify(args) {
|
|
|
554
760
|
if (!args.contract && args.proof && !args.proof.includes(" ")) {
|
|
555
761
|
process.stderr.write("\nRunning AI verification (5 models)...\n\n");
|
|
556
762
|
const result = await streamVerify(args.proof);
|
|
763
|
+
// The run was already going (usually the one upload started): print its outcome
|
|
764
|
+
// as proof-status reports it — the stream's frame never arrived here.
|
|
765
|
+
if (result && result.attached) {
|
|
766
|
+
print(result.status);
|
|
767
|
+
process.stderr.write(statusSummary(result.status) + "\n");
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
557
770
|
// SEC-055/058/059 at the third published surface. `passed` used to be
|
|
558
771
|
// `decision === "YES" && confidence >= 85`, i.e. a re-implementation of the
|
|
559
772
|
// three-tier rule from the PROOF's verdict — which answers a different question
|
|
@@ -573,6 +786,11 @@ async function cmdVerify(args) {
|
|
|
573
786
|
released,
|
|
574
787
|
alreadyReleased: result.alreadyReleased === true,
|
|
575
788
|
superseded: result.action === "SUPERSEDED",
|
|
789
|
+
// With SUPERSEDED: the TERMINAL status the row was already in (EXPIRED, COMPLETED),
|
|
790
|
+
// or null when another proof won it. It used to reach only the stderr line from
|
|
791
|
+
// `outcomeLine`, so a machine caller reading this JSON could not tell "closed —
|
|
792
|
+
// nothing moved, do not resubmit" from "another proof won" (round-3 pass 2).
|
|
793
|
+
settledAs: typeof result.settledAs === "string" ? result.settledAs : null,
|
|
576
794
|
complianceBlocked: result.complianceBlocked === true,
|
|
577
795
|
releaseHoldUntil: result.releaseHoldUntil ?? null,
|
|
578
796
|
panelUnavailable: result.timedOut === true,
|
|
@@ -601,7 +819,7 @@ async function cmdVerify(args) {
|
|
|
601
819
|
contract_id: args.contract,
|
|
602
820
|
milestone_id: args.milestone ?? undefined,
|
|
603
821
|
evidence,
|
|
604
|
-
});
|
|
822
|
+
}, API_KEY, MCP_SUBMIT_CLIENT_ABORT_MS);
|
|
605
823
|
|
|
606
824
|
// `decided: "elsewhere"` and `panelUnavailable` are 200 responses that carry NO
|
|
607
825
|
// `verdict` key, so `data.verdict ?? "unknown"` fell to the final arm below and told
|
|
@@ -624,8 +842,17 @@ async function cmdVerify(args) {
|
|
|
624
842
|
}
|
|
625
843
|
process.stderr.write("\n");
|
|
626
844
|
|
|
845
|
+
// With `decided: "elsewhere"`: the TERMINAL status the milestone was already in. EXPIRED
|
|
846
|
+
// is a funder cancel or the deadline, not "the funder's review, or a concurrent
|
|
847
|
+
// submission", and resubmitting to it is pointless.
|
|
848
|
+
const settledAs = verdict === "decided_elsewhere" && typeof data.settledAs === "string" ? data.settledAs : null;
|
|
849
|
+
|
|
627
850
|
let statusLine;
|
|
628
|
-
if (
|
|
851
|
+
if (settledAs) {
|
|
852
|
+
statusLine =
|
|
853
|
+
`ℹ️ This milestone was closed (${settledAs}) before this submission could settle it, so this submission ` +
|
|
854
|
+
"changed nothing and moved no funds itself. Your evidence was recorded. Do not resubmit to this milestone";
|
|
855
|
+
} else if (verdict === "decided_elsewhere") {
|
|
629
856
|
statusLine =
|
|
630
857
|
"ℹ️ This milestone was decided by another request while your proof was being verified — " +
|
|
631
858
|
"by the funder's review, or by a concurrent submission. Your evidence was recorded. " +
|
|
@@ -662,6 +889,7 @@ async function cmdVerify(args) {
|
|
|
662
889
|
// MONEY MOVED — not "the verdict was approved". A scheduled payout has not happened.
|
|
663
890
|
passed: verdict === "approved" && !releaseHoldUntil,
|
|
664
891
|
release_hold_until: releaseHoldUntil,
|
|
892
|
+
...(settledAs ? { settled_as: settledAs } : {}),
|
|
665
893
|
reasoning: data.reasoning ?? "",
|
|
666
894
|
on_chain_url: data.on_chain_url ?? null,
|
|
667
895
|
proof_id: data.proof_id ?? null,
|
|
@@ -679,12 +907,40 @@ async function cmdCreate(args) {
|
|
|
679
907
|
deadline.setDate(deadline.getDate() + deadlineDays);
|
|
680
908
|
const amountUSD = parseFloat(args.amount ?? "0");
|
|
681
909
|
|
|
910
|
+
// T07. The bar the panel judges against — kept OUT of the title, which is printed
|
|
911
|
+
// on the public certificate. Normalised by a copy of src/lib/milestone-bar-input.ts
|
|
912
|
+
// (milestone-bar-input-parity.test.ts runs it against the owner).
|
|
913
|
+
let criteriaRaw = typeof args.criteria === "string" ? args.criteria : undefined;
|
|
914
|
+
if (typeof args["criteria-file"] === "string") criteriaRaw = readFileSync(args["criteria-file"], "utf8");
|
|
915
|
+
let checklistRaw;
|
|
916
|
+
if (typeof args["checklist-file"] === "string") {
|
|
917
|
+
try {
|
|
918
|
+
checklistRaw = JSON.parse(readFileSync(args["checklist-file"], "utf8"));
|
|
919
|
+
} catch (e) {
|
|
920
|
+
err(`--checklist-file: not a JSON array of { title, severity?, id? } (${e.message})`);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
const reviewMode = typeof args["review-mode"] === "string" ? args["review-mode"].toUpperCase() : undefined;
|
|
924
|
+
if (reviewMode !== undefined && !["AUTO", "MANUAL", "MANUAL_AUTO"].includes(reviewMode)) {
|
|
925
|
+
err("--review-mode must be AUTO, MANUAL or MANUAL_AUTO");
|
|
926
|
+
}
|
|
927
|
+
let criteria, checklist;
|
|
928
|
+
try {
|
|
929
|
+
criteria = normalizeCriteria(criteriaRaw);
|
|
930
|
+
checklist = normalizeMilestoneChecklist(checklistRaw);
|
|
931
|
+
} catch (e) {
|
|
932
|
+
err(e.message);
|
|
933
|
+
}
|
|
934
|
+
|
|
682
935
|
const data = await apiPost("/api/contracts", {
|
|
683
936
|
milestones: [{
|
|
684
937
|
title: args.title,
|
|
685
938
|
amountUSD,
|
|
686
939
|
cancelAfter: deadline.toISOString(),
|
|
940
|
+
...(criteria !== undefined ? { verificationCriteria: criteria } : {}),
|
|
941
|
+
...(checklist !== undefined ? { verificationChecklist: checklist } : {}),
|
|
687
942
|
}],
|
|
943
|
+
...(reviewMode !== undefined ? { agentReviewMode: reviewMode } : {}),
|
|
688
944
|
});
|
|
689
945
|
|
|
690
946
|
const contractUrl = `${BASE_URL}/contract/${data.contractId}`;
|
|
@@ -742,7 +998,25 @@ async function cmdSubmit(args) {
|
|
|
742
998
|
args.proof
|
|
743
999
|
);
|
|
744
1000
|
print(data);
|
|
745
|
-
|
|
1001
|
+
// Upload starts verification by itself — `verify --proof` would only race it.
|
|
1002
|
+
process.stderr.write(`\nProof submitted; verification has started. Wait for the verdict: cascrow status --proof ${data.proofId} --wait\n\n`);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
async function cmdStatus(args) {
|
|
1006
|
+
if (!API_KEY) err("CASCROW_API_KEY is not set");
|
|
1007
|
+
if (!args.proof) err("--proof is required");
|
|
1008
|
+
if (args.wait) {
|
|
1009
|
+
process.stderr.write("\nWaiting for the verification to settle (up to ~2 min)...\n\n");
|
|
1010
|
+
const s = await waitForProof(args.proof, 120_000);
|
|
1011
|
+
print(s);
|
|
1012
|
+
process.stderr.write("\n" + statusSummary(s) + "\n\n");
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
const data = await apiGet(`/api/agent/proof-status/${encodeURIComponent(args.proof)}`);
|
|
1016
|
+
print(data);
|
|
1017
|
+
process.stderr.write(
|
|
1018
|
+
"\n" + (data.pending && data.milestoneClosed !== true ? `⏳ Still verifying${data.claimPhase === "orphaned" ? " — the run was interrupted" : ""}.` : statusSummary({ ...data, proofId: args.proof })) + "\n\n"
|
|
1019
|
+
);
|
|
746
1020
|
}
|
|
747
1021
|
|
|
748
1022
|
async function cmdGet(args) {
|
|
@@ -869,6 +1143,7 @@ try {
|
|
|
869
1143
|
case "fund": await cmdFund(args); break;
|
|
870
1144
|
case "escrow-fund": await cmdEscrowFund(args); break;
|
|
871
1145
|
case "submit": await cmdSubmit(args); break;
|
|
1146
|
+
case "status": await cmdStatus(args); break;
|
|
872
1147
|
case "get": await cmdGet(args); break;
|
|
873
1148
|
case "join": await cmdJoin(args); break;
|
|
874
1149
|
case "handoff": await cmdHandoff(args); break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cascrow-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "CLI for Cascrow — trust infrastructure for the agent economy: AI verification, on-chain reputation, and milestone escrow on the XRP Ledger",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|