@bolloon/bolloon-agent 0.4.26 → 0.4.28

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.
@@ -203,6 +203,35 @@ export class ExecutionSupervisor {
203
203
  this.reconciledOnce = true;
204
204
  if (report.reconciled.interrupted.length)
205
205
  this.log(`[supervisor] 对账: ${report.reconciled.interrupted.length} 条僵尸 run → interrupted`);
206
+ // 1.1 (2026-09-18, Phase 3): 支付中断对账 —— 只把结算事实钉死, **绝不代替付款方花钱**
207
+ try {
208
+ const { reconcileInterruptedPayments } = await import('./x402/payment-recovery.js');
209
+ const home = process.env.HOME || os.homedir();
210
+ report.payments = await reconcileInterruptedPayments({
211
+ home,
212
+ reconcile: async (rec) => {
213
+ // 对账依据: 记录里既有的链上证据。查真链上历史属 Phase 1 (需 RPC/facilitator), 这里不猜。
214
+ if (rec.chainSettled === true && rec.txHash)
215
+ return { fact: 'payment_verified', txHash: rec.txHash, note: '对账: 记录自带 txHash 与链上结算事实' };
216
+ if (rec.txHash)
217
+ return { fact: 'payment_verified', txHash: rec.txHash, note: '对账: 发现 txHash → 结算事实升级' };
218
+ if (rec.paymentReceipt)
219
+ return { fact: 'unknown', note: '有支付凭据但无 txHash → 维持 unknown, 需 facilitator 澄清 (不重付)' };
220
+ return { fact: 'unpaid', note: '没有任何支付凭据 → 确认没付过 (可安全重试)' };
221
+ },
222
+ persist: async (transactionId, patch, event) => {
223
+ const { updateTransaction } = await import('./x402/transaction-store.js');
224
+ await updateTransaction(transactionId, { ...patch, event }, home);
225
+ },
226
+ });
227
+ if (report.payments.reconciled.length || report.payments.mustNotRepay.length || report.payments.goalsWoken.length) {
228
+ this.log(`[supervisor] 支付对账: ${report.payments.reconciled.length} 条已钉结算事实 · ${report.payments.mustNotRepay.length} 条绝不重付 · `
229
+ + `${report.payments.awaitingPayment.length} 条等付款 · **唤醒 ${report.payments.goalsWoken.length} 个 Goal** · ${report.payments.goalsFlagged.length} 个转人工`);
230
+ }
231
+ }
232
+ catch (err) {
233
+ report.payments.errors.push(String(err?.message || err).slice(0, 120));
234
+ }
206
235
  }
207
236
  report.supervised = await superviseRuns();
208
237
  // 1.5 (2026-09-16, 2-C.4): 外部等待超时 → 明确转人工 (不允许无限等待)
@@ -256,6 +285,7 @@ export class ExecutionSupervisor {
256
285
  return {
257
286
  at: new Date().toISOString(), owner: this.owner, tick: this.tickCount,
258
287
  reconciled: { interrupted: [], stillRunning: [], failed: [] },
288
+ payments: { scanned: 0, reconciled: [], awaitingPayment: [], mustNotRepay: [], closed: [], goalsWoken: [], goalsFlagged: [], errors: [] },
259
289
  supervised: { stalled: [], failed: [] },
260
290
  claimed: [], executed: [], skipped: [], errors: [], dryRun: !this.runner,
261
291
  };
@@ -278,6 +308,27 @@ export class ExecutionSupervisor {
278
308
  this.emit({ kind: 'retry_woke', goalId: goal.goalId, message: `到点唤醒, 第 ${(goal.continuation?.attempts || 0) + 1} 次自动继续` });
279
309
  this.log(`[supervisor] goal=${goal.goalId} retry_wait 到点 → 唤醒并清 wakeAt`);
280
310
  }
311
+ // ★ 2026-09-18 (M2 收口): `bolloon task` 建的目标走**同一个恢复决策函数**
312
+ // (`decideTaskRecovery`) —— CLI `task --resume` 与 Supervisor 不再各判一次。
313
+ // 决策说"不能动钱/已经执行过"就跳过 (不重复付款、不重复执行非幂等操作)。
314
+ if (goal.createdBy === 'cli:task' && process.env.BOLLOON_SUPERVISOR_TASK_RESUME !== '0') {
315
+ try {
316
+ const { decideTaskRecovery, resumeTask } = await import('./task/task-runner.js');
317
+ const decision = await decideTaskRecovery({ goalId: goal.goalId });
318
+ const actionable = ['retry_payment', 'deliver', 'verify'].includes(decision.action);
319
+ if (!actionable) {
320
+ report.skipped.push({ goalId: goal.goalId, reason: `任务恢复决策=${decision.action} (${decision.reason}) → 本周期不动` });
321
+ return { goalId: goal.goalId, status: 'task_no_action' };
322
+ }
323
+ const res = await resumeTask({ goalId: goal.goalId });
324
+ this.log(`[supervisor] task 目标接回: goal=${goal.goalId} decision=${decision.action} → 状态 ${res.card.status}`);
325
+ this.emit({ kind: res.ok ? 'goal_done' : 'needs_human', goalId: goal.goalId, message: `任务接回: ${decision.action} → ${res.card.status}` });
326
+ return { goalId: goal.goalId, runId: res.runId, status: res.card.status, error: res.ok ? undefined : res.reason };
327
+ }
328
+ catch (err) {
329
+ return { goalId: goal.goalId, status: 'task_resume_failed', error: String(err?.message || err).slice(0, 160) };
330
+ }
331
+ }
281
332
  const prevRunId = goal.currentRunId;
282
333
  const prevRun = prevRunId ? await readRun(prevRunId) : null;
283
334
  const plan = prevRunId ? await buildContinuationPlan(prevRunId).catch(() => null) : null;
@@ -0,0 +1,175 @@
1
+ /**
2
+ * p2p-info.ts — 本机 P2P 连接信息出口 (2026-09-18)
3
+ *
4
+ * 目的: 把"我这台机器/这台手机怎么被别的智能体拨通"变成**一条可抄走的连接信息**
5
+ * (peerId + 可拨入 multiaddr),好递给名片/交接串/对方智能体。
6
+ * 诚实原则: 只报真实拿到的;拿不到就说清原因与下一步,不编 peerId、不假装"能连通"。
7
+ */
8
+ import * as fs from 'fs';
9
+ import * as os from 'os';
10
+ import * as path from 'path';
11
+ function home() { return process.env.HOME || os.homedir(); }
12
+ function readJsonSafe(p) {
13
+ try {
14
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ function readIdentity() {
21
+ const p = path.join(home(), '.bolloon', 'identity', 'user.json');
22
+ const j = readJsonSafe(p);
23
+ return { did: j?.did, name: j?.name };
24
+ }
25
+ function readGatewayJoin() {
26
+ return readJsonSafe(path.join(home(), '.bolloon', 'gateway-join.json'));
27
+ }
28
+ /** 确保地址带 /p2p/<peerId> 段 (没有就补上, 否则对端拨不通) */
29
+ export function ensureDialable(addr, peerId) {
30
+ const a = String(addr || '').trim();
31
+ if (!a)
32
+ return a;
33
+ if (a.includes('/p2p/'))
34
+ return a;
35
+ return peerId ? `${a}/p2p/${peerId}` : a;
36
+ }
37
+ export async function getLocalP2pInfo() {
38
+ const info = { ok: false, source: 'none', multiaddrs: [], relayAddrs: [], isRelay: false };
39
+ const id = readIdentity();
40
+ info.did = id.did;
41
+ info.name = id.name;
42
+ // ① 先看本进程里节点是否真的在跑
43
+ try {
44
+ const mod = await import('../network/p2p.js');
45
+ const net = mod.p2pNetwork;
46
+ const node = net?.getNode?.();
47
+ if (net && node) {
48
+ const peerId = String(net.getNodePeerId?.() || '');
49
+ if (peerId) {
50
+ info.source = 'live';
51
+ info.peerId = peerId;
52
+ // 优先给浏览器/手机能用的 ws 地址; 没有 ws 时回退到节点真实全部地址 (不编造)
53
+ let raw = (net.getWsMultiaddrs?.() || []).map((m) => String(m));
54
+ if (!raw.length) {
55
+ try {
56
+ raw = (node.getMultiaddrs?.() || []).map((m) => String(m));
57
+ }
58
+ catch {
59
+ raw = [];
60
+ }
61
+ }
62
+ info.multiaddrs = raw.map((m) => ensureDialable(m, peerId));
63
+ try {
64
+ const ra = (net.getRelayAddrs?.() || []).map((m) => String(m));
65
+ info.relayAddrs = ra.map((m) => ensureDialable(m, peerId));
66
+ }
67
+ catch { /* 没有中继也能继续 */ }
68
+ try {
69
+ const svc = net.getRelayServiceInfo?.();
70
+ if (svc) {
71
+ info.isRelay = !!svc.active;
72
+ info.relayService = {
73
+ active: !!svc.active,
74
+ protocol: svc.protocol,
75
+ maxReservations: svc.maxReservations,
76
+ reservations: svc.reservations,
77
+ };
78
+ }
79
+ }
80
+ catch { /* 忽略 */ }
81
+ try {
82
+ const nat = net.getNatStatus?.();
83
+ if (nat)
84
+ info.natStatus = String(nat.status || nat.kind || JSON.stringify(nat).slice(0, 60));
85
+ }
86
+ catch { /* 忽略 */ }
87
+ }
88
+ }
89
+ }
90
+ catch { /* 客户端环境没有 p2p 模块 → 走落盘记录 */ }
91
+ // ② 落盘记录兜底 (CLI 一次性命令里节点通常没跑)
92
+ const gj = readGatewayJoin();
93
+ if (gj) {
94
+ info.joinedAt = gj.joinedAt;
95
+ info.capabilities = Array.isArray(gj.capabilities) ? gj.capabilities : undefined;
96
+ if (!info.peerId && gj.peerId) {
97
+ info.peerId = String(gj.peerId);
98
+ info.source = 'persisted';
99
+ }
100
+ if (!info.did && gj.did)
101
+ info.did = String(gj.did);
102
+ if (!info.name && gj.name)
103
+ info.name = String(gj.name);
104
+ }
105
+ if (info.peerId && !info.multiaddrs.length) {
106
+ info.note = '只拿到 peerId (落盘记录), 当前进程没有运行中的 P2P 节点 → 可拨入地址需要一个在跑的节点来生成 '
107
+ + '(启动: `bolloon --web` 或让智能体执行 join_global_gateway; 之后在此重跑 `bolloon p2p`)';
108
+ }
109
+ else if (!info.peerId) {
110
+ info.note = '还没有本机 peerId: 先让智能体入网 (read https://bolloon.cn/bolloon-gateway-join.md) 或启动 P2P 节点, 再跑 `bolloon p2p`';
111
+ }
112
+ else if (!info.multiaddrs.length) {
113
+ info.note = '节点在跑, 但当前没有被拨入地址 (可能只有拨出能力); 若在手机端需先建立 relay 预约';
114
+ }
115
+ else {
116
+ info.note = undefined;
117
+ }
118
+ info.ok = !!info.peerId;
119
+ return info;
120
+ }
121
+ /** 人可读 + 可直接抄进小工具/名片 (peerId 与 multiaddr 分开列, 方便逐项粘贴) */
122
+ export function formatP2pInfoText(info) {
123
+ const lines = [];
124
+ lines.push('── 本机 P2P 连接信息 ──────────────────────────────');
125
+ lines.push(`来源: ${info.source === 'live' ? '运行中的节点 (live)' : info.source === 'persisted' ? '落盘记录 (persisted)' : '无'}`);
126
+ if (info.name)
127
+ lines.push(`名称: ${info.name}`);
128
+ if (info.did)
129
+ lines.push(`身份: ${info.did}`);
130
+ lines.push(`peerId: ${info.peerId || '(未拿到)'}`);
131
+ if (info.capabilities?.length)
132
+ lines.push(`能力: ${info.capabilities.join(', ')}`);
133
+ if (info.natStatus)
134
+ lines.push(`NAT: ${info.natStatus}`);
135
+ lines.push(`本机是中继: ${info.isRelay ? '是' : '否'}${info.relayService?.active ? ` (协议 ${info.relayService.protocol || '-'}, 预约 ${info.relayService.reservations ?? '-'}/${info.relayService.maxReservations ?? '-'})` : ''}`);
136
+ if (info.multiaddrs.length) {
137
+ lines.push('可拨入地址:');
138
+ for (const m of info.multiaddrs.slice(0, 6))
139
+ lines.push(` ${m}`);
140
+ }
141
+ else {
142
+ lines.push('可拨入地址: (当前没有 —— 对端暂时拨不进来)');
143
+ }
144
+ if (info.relayAddrs.length) {
145
+ lines.push('经中继可拨入:');
146
+ for (const m of info.relayAddrs.slice(0, 4))
147
+ lines.push(` ${m}`);
148
+ }
149
+ if (info.note)
150
+ lines.push(`说明: ${info.note}`);
151
+ lines.push('── 抄进小工具: 地址填上面的 multiaddr (含 /p2p/ 段), peerId 填上面的 peerId ──');
152
+ return lines.join('\n');
153
+ }
154
+ /** 机器可读 (给小工具/App/验收脚本用; 字段名与名片里的 p2p 结构对齐) */
155
+ export function formatP2pInfoJson(info) {
156
+ const primary = info.relayAddrs[0] || info.multiaddrs[0] || '';
157
+ return JSON.stringify({
158
+ schema: 'bolloon-p2p-info/1',
159
+ ok: info.ok,
160
+ source: info.source,
161
+ did: info.did || '',
162
+ name: info.name || '',
163
+ peerId: info.peerId || '',
164
+ multiaddr: primary,
165
+ multiaddrs: info.multiaddrs,
166
+ relayAddrs: info.relayAddrs,
167
+ isRelay: info.isRelay,
168
+ relayService: info.relayService || null,
169
+ natStatus: info.natStatus || null,
170
+ capabilities: info.capabilities || [],
171
+ note: info.note || null,
172
+ /** 直接可用的名片字段 (小工具 agent-card 的 p2p 结构) */
173
+ cardP2p: { peerId: info.peerId || '', multiaddr: primary, relay: info.isRelay ? (info.peerId || '') : '' },
174
+ }, null, 2);
175
+ }
@@ -485,6 +485,21 @@ export function argsDigestOf(v) {
485
485
  * 追加一步 (工具调用后立即落盘) —— 崩在这里也能看到做到哪一步。
486
486
  * 锁内读改写: 并发调用不会互相覆盖步骤。
487
487
  */
488
+ /**
489
+ * 追加证据到 Run (2026-09-16): 交易等"外部事实"要能进 Run 的 evidence,
490
+ * 而不是只留在工具内部 (支付必须可审计)。
491
+ */
492
+ export async function addRunEvidence(runId, lines) {
493
+ return withRunLock(runId, () => coreWrite('addRunEvidence', runId, async () => {
494
+ const rec = await readRun(runId);
495
+ if (!rec)
496
+ return null;
497
+ const merged = Array.from(new Set([...(rec.evidence || []), ...lines.map((l) => String(l).slice(0, 300))])).slice(-50);
498
+ rec.evidence = merged;
499
+ await writeRun(rec);
500
+ return rec;
501
+ }));
502
+ }
488
503
  export async function recordStep(runId, step) {
489
504
  return withRunLock(runId, () => coreWrite('recordStep', runId, async () => {
490
505
  const rec = await readRun(runId);
@@ -0,0 +1,149 @@
1
+ /**
2
+ * local-seller.ts — M1 的"卖方" (本地 Registry 节点)
3
+ *
4
+ * M1 只做**本地 Registry**: 卖方就是你自己本机的报价端点。
5
+ * 这里**不重新实现协议** —— 直接把项目真实的卖方路由
6
+ * (`src/web/routes-x402-info.ts: registerX402InfoRoutes`) 挂到一个极小的
7
+ * express 风格适配器上, 用真 HTTP 跑真 402 → 付款校验 → 签名信封。
8
+ *
9
+ * 注意: 卖方路由内部用 `os.homedir()` 找 `~/.bolloon/{identity.json, x402-info/}`,
10
+ * 所以调用方若做隔离测试, 需要在启动前设好 `process.env.HOME`。
11
+ */
12
+ import * as http from 'http';
13
+ import * as fs from 'fs';
14
+ import * as os from 'os';
15
+ import * as path from 'path';
16
+ function matchPath(pattern, actual) {
17
+ const p = pattern.split('/').filter(Boolean);
18
+ const a = actual.split('/').filter(Boolean);
19
+ if (pattern.endsWith('/') && p.length !== a.length)
20
+ return { hit: false, params: {} };
21
+ if (p.length !== a.length)
22
+ return { hit: false, params: {} };
23
+ const params = {};
24
+ for (let i = 0; i < p.length; i++) {
25
+ if (p[i].startsWith(':'))
26
+ params[p[i].slice(1)] = decodeURIComponent(a[i]);
27
+ else if (p[i] !== a[i])
28
+ return { hit: false, params: {} };
29
+ }
30
+ return { hit: true, params };
31
+ }
32
+ function makeRes() {
33
+ let settle;
34
+ const done = new Promise((r) => { settle = r; });
35
+ const headers = {};
36
+ const res = {
37
+ _status: 200,
38
+ status(n) { res._status = n; return res; },
39
+ set(k, v) { headers[k] = String(v); return res; },
40
+ json(obj) {
41
+ headers['content-type'] = 'application/json';
42
+ settle({ status: res._status, headers, body: JSON.stringify(obj ?? null) });
43
+ return res;
44
+ },
45
+ send(text) {
46
+ settle({ status: res._status, headers, body: String(text ?? '') });
47
+ return res;
48
+ },
49
+ };
50
+ return { res, done };
51
+ }
52
+ /** 极小的 express 风格 app (只实现卖方路由用到的 get/post/delete + res.json/status/set) */
53
+ export function createMiniApp() {
54
+ const routes = [];
55
+ const add = (method) => (p, h) => { routes.push({ method, path: p, handler: h }); };
56
+ const app = { get: add('GET'), post: add('POST'), delete: add('DELETE') };
57
+ const dispatch = async (method, url, headers, body) => {
58
+ const u = new URL(url, 'http://127.0.0.1');
59
+ for (const r of routes) {
60
+ if (r.method !== method)
61
+ continue;
62
+ const m = matchPath(r.path, u.pathname);
63
+ if (!m.hit)
64
+ continue;
65
+ const { res, done } = makeRes();
66
+ let parsed = undefined;
67
+ if (body) {
68
+ try {
69
+ parsed = JSON.parse(body);
70
+ }
71
+ catch {
72
+ parsed = undefined;
73
+ }
74
+ }
75
+ const req = {
76
+ method,
77
+ url,
78
+ headers,
79
+ params: m.params,
80
+ query: Object.fromEntries(u.searchParams.entries()),
81
+ body: parsed,
82
+ protocol: 'http',
83
+ };
84
+ try {
85
+ await r.handler(req, res);
86
+ }
87
+ catch (e) {
88
+ return { status: 500, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ error: String(e?.message || e) }) };
89
+ }
90
+ return await done;
91
+ }
92
+ return null;
93
+ };
94
+ return { app, dispatch };
95
+ }
96
+ /** 卖方的 DIAP 身份 (签发信封用) — 缺失就生成一个本机身份, 不静默跳过 */
97
+ export async function ensureSellerIdentity(home = os.homedir()) {
98
+ const dir = path.join(home, '.bolloon');
99
+ const file = path.join(dir, 'identity.json');
100
+ if (fs.existsSync(file)) {
101
+ try {
102
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
103
+ return { created: false, did: raw?.did };
104
+ }
105
+ catch {
106
+ return { created: false };
107
+ }
108
+ }
109
+ try {
110
+ const { KeyManager } = await import('@diap/sdk');
111
+ const kp = KeyManager.generate();
112
+ fs.mkdirSync(dir, { recursive: true });
113
+ await KeyManager.saveToFile(kp, file);
114
+ return { created: true, did: kp?.did };
115
+ }
116
+ catch (e) {
117
+ return { created: false };
118
+ }
119
+ }
120
+ /** 启动本地卖方节点: 真 HTTP + 项目真实卖方路由 */
121
+ export async function startLocalSeller(opts = {}) {
122
+ const { registerX402InfoRoutes } = await import('../../web/routes-x402-info.js');
123
+ const { app, dispatch } = createMiniApp();
124
+ registerX402InfoRoutes(app);
125
+ const { listInfo } = await import('../x402/paid-info-store.js');
126
+ const server = http.createServer((req, res) => {
127
+ const chunks = [];
128
+ req.on('data', (c) => chunks.push(c));
129
+ req.on('end', async () => {
130
+ const body = Buffer.concat(chunks).toString('utf8');
131
+ const out = await dispatch(req.method || 'GET', req.url || '/', req.headers, body);
132
+ if (!out) {
133
+ res.writeHead(404, { 'content-type': 'application/json' });
134
+ res.end(JSON.stringify({ error: 'not found' }));
135
+ return;
136
+ }
137
+ res.writeHead(out.status, out.headers);
138
+ res.end(out.body);
139
+ });
140
+ });
141
+ await new Promise((resolve) => server.listen(opts.port ?? 0, '127.0.0.1', () => resolve()));
142
+ const port = server.address()?.port;
143
+ return {
144
+ url: `http://127.0.0.1:${port}`,
145
+ port,
146
+ itemCount: async () => (await listInfo()).length,
147
+ close: async () => { await new Promise((resolve) => server.close(() => resolve())); },
148
+ };
149
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * report-card.ts — M1 薄层 ③: 报告卡
3
+ *
4
+ * M1 **唯一面向人的主出口**。用户只看到这里的东西:
5
+ * 任务 / 结论 / 本次使用(Skill·版本·花费·来源) / 三项校验 / 状态(4 态) / 证据指针 / 卡在哪。
6
+ *
7
+ * 两条硬门 (leo 定, 任一不满足 → M1 失败):
8
+ * 买到 Skill 但没有执行 → 卡片必须"需要你处理", 不许变绿
9
+ * 执行成功但没有报告/证据 → 卡片必须"需要你处理", 不许变绿
10
+ *
11
+ * 内部 10 态生命周期 / 8 态结算事实**只在这里被映射成 4 个人类状态**, 不外泄术语。
12
+ */
13
+ /** 内部生命周期 → 人类 4 态 (术语不外泄) */
14
+ export function humanStatusFrom(opts) {
15
+ if (opts.verdict === 'blocked')
16
+ return '需要你处理';
17
+ const lc = String(opts.lifecycle || '');
18
+ switch (lc) {
19
+ case 'verified':
20
+ return '已完成';
21
+ case 'settled':
22
+ case 'delivered':
23
+ return '正在执行';
24
+ case 'discovered':
25
+ case 'quoted':
26
+ case 'payment_required':
27
+ case 'paying':
28
+ return '正在获取能力';
29
+ case 'policy_denied':
30
+ case 'delivery_failed':
31
+ case 'verification_failed':
32
+ case 'disputed':
33
+ case 'failed':
34
+ return '需要你处理';
35
+ default:
36
+ break;
37
+ }
38
+ switch (opts.stage) {
39
+ case 'prepare':
40
+ return '准备中';
41
+ case 'acquire':
42
+ return '正在获取能力';
43
+ case 'execute':
44
+ return '正在执行';
45
+ default:
46
+ return '已完成';
47
+ }
48
+ }
49
+ /**
50
+ * 组装报告卡。两条硬门在这里落地 (不是靠调用方记得检查):
51
+ * 只要"付了没执行"或"执行了没证据", 一律 `需要你处理` + 结论降级为"证据不足"。
52
+ */
53
+ export function buildReportCard(input) {
54
+ const checks = {
55
+ outputContract: input.outputContract ?? '未执行',
56
+ resourceVerified: input.resourceVerified ?? '未执行',
57
+ taskEvidence: input.evidenceComplete ? '完整' : '不完整',
58
+ };
59
+ let status = humanStatusFrom({ stage: input.stage, lifecycle: input.lifecycle, verdict: input.blocker ? 'blocked' : 'ok' });
60
+ let hardGate;
61
+ let conclusion = input.conclusion ?? (status === '已完成' ? '证据不足' : '证据不足');
62
+ let blocker = input.blocker;
63
+ if (input.paid && !input.executed) {
64
+ hardGate = 'bought_not_executed';
65
+ status = '需要你处理';
66
+ conclusion = '证据不足';
67
+ blocker = blocker || '买到了 Skill 但没有执行 —— 这一次不算完成任务 (M1 硬门)';
68
+ }
69
+ else if (input.executed && !input.evidenceComplete) {
70
+ hardGate = 'executed_without_evidence';
71
+ status = '需要你处理';
72
+ conclusion = '证据不足';
73
+ blocker = blocker || '执行了但没有留下完整证据 —— 这一次不算完成任务 (M1 硬门)';
74
+ }
75
+ else if (input.outputContract === '未通过') {
76
+ status = '需要你处理';
77
+ conclusion = '证据不足';
78
+ blocker = blocker || '输出不符合资源契约 —— 不计成功 (M1 硬门)';
79
+ }
80
+ if (status === '已完成' && (checks.taskEvidence !== '完整' || checks.resourceVerified !== '通过' || checks.outputContract !== '通过')) {
81
+ // 三项校验没全过就不许显示"已完成" (防假绿)
82
+ status = '需要你处理';
83
+ hardGate = hardGate || 'executed_without_evidence';
84
+ conclusion = '证据不足';
85
+ blocker = blocker || `三项校验未全过 (输出契约=${checks.outputContract} · 资源验证=${checks.resourceVerified} · 证据=${checks.taskEvidence})`;
86
+ }
87
+ return {
88
+ task: input.task,
89
+ conclusion,
90
+ conclusionDetail: input.conclusionDetail,
91
+ skill: input.skill,
92
+ cost: input.cost,
93
+ payment: input.payment,
94
+ sources: input.sources,
95
+ checks,
96
+ status,
97
+ executed: input.executed,
98
+ paid: input.paid,
99
+ durationMs: input.durationMs,
100
+ evidenceRef: input.evidenceRef,
101
+ blocker,
102
+ hardGate,
103
+ budgetLines: input.budgetLines,
104
+ };
105
+ }
106
+ /** CLI 文本渲染 —— M1 的唯一主出口形状 */
107
+ export function renderReportCard(card) {
108
+ const L = [];
109
+ L.push(`任务: ${card.task}`);
110
+ L.push('');
111
+ L.push(`结论: ${card.conclusion}${card.conclusionDetail ? ` (${card.conclusionDetail})` : ''}`);
112
+ L.push('');
113
+ L.push('本次使用:');
114
+ if (card.skill)
115
+ L.push(`- Skill: ${card.skill.name}${card.skill.version ? ` @ ${card.skill.version}` : ''}`);
116
+ if (card.cost)
117
+ L.push(`- 花费: ${card.cost.amount} ${card.cost.currency} (${card.cost.network})`);
118
+ if (card.payment) {
119
+ const modeZh = card.payment.mode === 'local-dev' ? '本机联调 (local-dev)' : card.payment.mode === 'facilitator' ? 'facilitator' : card.payment.mode;
120
+ L.push(`- 支付方式: ${modeZh}`);
121
+ L.push(`- 链上已验证: ${card.payment.chainSettled ? '是' : '否'}${card.payment.chainSettled ? '' : ' (本机联调不冒充链上结算)'}`);
122
+ }
123
+ if (card.sources)
124
+ L.push(`- 来源: ${card.sources.length} 个${card.sources.length ? ` (${card.sources.slice(0, 3).join(', ')}${card.sources.length > 3 ? ', …' : ''})` : ''}`);
125
+ L.push(`- 输出契约: ${card.checks.outputContract}`);
126
+ L.push(`- 资源验证: ${card.checks.resourceVerified}`);
127
+ L.push(`- 任务证据: ${card.checks.taskEvidence}`);
128
+ if (typeof card.durationMs === 'number')
129
+ L.push(`- 耗时: ${(card.durationMs / 1000).toFixed(1)} 秒`);
130
+ L.push(`- 状态: ${card.status}`);
131
+ if (card.blocker) {
132
+ L.push('');
133
+ L.push(`卡在哪: ${card.blocker}`);
134
+ }
135
+ if (card.budgetLines && card.budgetLines.length) {
136
+ L.push('');
137
+ L.push('预算:');
138
+ for (const b of card.budgetLines)
139
+ L.push(`- ${b}`);
140
+ }
141
+ L.push('');
142
+ const ref = card.evidenceRef;
143
+ const parts = [ref.goalId ? `Goal ${ref.goalId}` : null, ref.runId ? `Run ${ref.runId}` : null, ref.transactionId ? `交易 ${ref.transactionId}` : null].filter(Boolean);
144
+ L.push(parts.length ? `查看完整证据: ${parts.join(' · ')}` : '查看完整证据: (无)');
145
+ return L.join('\n');
146
+ }