@bolloon/bolloon-agent 0.4.32 → 0.5.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.
Files changed (157) hide show
  1. package/dist/agents/chain/explorer.js +52 -0
  2. package/dist/agents/chain/index.js +2 -0
  3. package/dist/agents/chain/onchain-trade.js +15 -1
  4. package/dist/agents/contacts/chain.js +27 -7
  5. package/dist/agents/execution-supervisor.js +493 -35
  6. package/dist/agents/external-events.js +35 -6
  7. package/dist/agents/gateway-group.js +100 -12
  8. package/dist/agents/goal-criteria.js +10 -3
  9. package/dist/agents/goal-flywheel/block-executor.js +346 -0
  10. package/dist/agents/goal-flywheel/continuation-decision.js +592 -0
  11. package/dist/agents/goal-flywheel/goal-change.js +754 -0
  12. package/dist/agents/goal-flywheel/index.js +26 -0
  13. package/dist/agents/goal-flywheel/memory-layers.js +227 -0
  14. package/dist/agents/goal-flywheel/run-closure.js +809 -0
  15. package/dist/agents/goal-flywheel/skill-candidate.js +628 -0
  16. package/dist/agents/goal-flywheel/types.js +329 -0
  17. package/dist/agents/goal-flywheel/wiring/change.js +288 -0
  18. package/dist/agents/goal-flywheel/wiring/closure.js +697 -0
  19. package/dist/agents/goal-flywheel/wiring/continuation.js +432 -0
  20. package/dist/agents/goal-flywheel/wiring/contract.js +270 -0
  21. package/dist/agents/goal-flywheel/wiring/index.js +26 -0
  22. package/dist/agents/goal-flywheel/wiring/monitor.js +254 -0
  23. package/dist/agents/goal-flywheel/wiring/seams.js +592 -0
  24. package/dist/agents/goal-flywheel/work-contract.js +478 -0
  25. package/dist/agents/goal-flywheel/work-monitor.js +488 -0
  26. package/dist/agents/goal-flywheel-wiring.js +1870 -0
  27. package/dist/agents/goal-state-reducer.js +406 -0
  28. package/dist/agents/goal-store.js +35 -1
  29. package/dist/agents/mobile-flywheel-view.js +144 -0
  30. package/dist/agents/mobile-task-actions-verify.js +74 -0
  31. package/dist/agents/mobile-task-actions.js +178 -0
  32. package/dist/agents/mobile-task-views.js +199 -0
  33. package/dist/agents/network-pulse.js +525 -27
  34. package/dist/agents/pi-sdk.js +75 -20
  35. package/dist/agents/run-store.js +18 -5
  36. package/dist/agents/skill-readiness.js +52 -10
  37. package/dist/agents/subagent-manager.js +209 -3
  38. package/dist/agents/task/task-onchain-runner.js +19 -1
  39. package/dist/agents/task/task-runner.js +36 -6
  40. package/dist/agents/task-board.js +702 -0
  41. package/dist/agents/task-group.js +524 -0
  42. package/dist/agents/task-public-text.js +89 -0
  43. package/dist/cli/commands/index.js +3 -0
  44. package/dist/cli/commands/tasks.js +977 -8
  45. package/dist/cli/identity-command.js +177 -0
  46. package/dist/cli/protocol-envelope.js +4 -0
  47. package/dist/cli/setup-wizard.js +71 -0
  48. package/dist/cli/update-commands.js +150 -24
  49. package/dist/cli-entry.js +12 -2
  50. package/dist/electron-build/agents/chain/explorer.js +59 -0
  51. package/dist/electron-build/agents/chain/explorer.js.map +1 -0
  52. package/dist/electron-build/agents/contacts/chain.js +26 -6
  53. package/dist/electron-build/agents/contacts/chain.js.map +1 -1
  54. package/dist/electron-build/agents/execution-supervisor.js +492 -34
  55. package/dist/electron-build/agents/execution-supervisor.js.map +1 -1
  56. package/dist/electron-build/agents/external-events.js +34 -5
  57. package/dist/electron-build/agents/external-events.js.map +1 -1
  58. package/dist/electron-build/agents/goal-criteria.js +10 -3
  59. package/dist/electron-build/agents/goal-criteria.js.map +1 -1
  60. package/dist/electron-build/agents/goal-flywheel/continuation-decision.js +598 -0
  61. package/dist/electron-build/agents/goal-flywheel/continuation-decision.js.map +1 -0
  62. package/dist/electron-build/agents/goal-flywheel/goal-change.js +773 -0
  63. package/dist/electron-build/agents/goal-flywheel/goal-change.js.map +1 -0
  64. package/dist/electron-build/agents/goal-flywheel/memory-layers.js +269 -0
  65. package/dist/electron-build/agents/goal-flywheel/memory-layers.js.map +1 -0
  66. package/dist/electron-build/agents/goal-flywheel/run-closure.js +818 -0
  67. package/dist/electron-build/agents/goal-flywheel/run-closure.js.map +1 -0
  68. package/dist/electron-build/agents/goal-flywheel/skill-candidate.js +642 -0
  69. package/dist/electron-build/agents/goal-flywheel/skill-candidate.js.map +1 -0
  70. package/dist/electron-build/agents/goal-flywheel/types.js +333 -0
  71. package/dist/electron-build/agents/goal-flywheel/types.js.map +1 -0
  72. package/dist/electron-build/agents/goal-flywheel/wiring/change.js +294 -0
  73. package/dist/electron-build/agents/goal-flywheel/wiring/change.js.map +1 -0
  74. package/dist/electron-build/agents/goal-flywheel/wiring/closure.js +742 -0
  75. package/dist/electron-build/agents/goal-flywheel/wiring/closure.js.map +1 -0
  76. package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js +444 -0
  77. package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js.map +1 -0
  78. package/dist/electron-build/agents/goal-flywheel/wiring/contract.js +279 -0
  79. package/dist/electron-build/agents/goal-flywheel/wiring/contract.js.map +1 -0
  80. package/dist/electron-build/agents/goal-flywheel/wiring/index.js +111 -0
  81. package/dist/electron-build/agents/goal-flywheel/wiring/index.js.map +1 -0
  82. package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js +264 -0
  83. package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js.map +1 -0
  84. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js +612 -0
  85. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js.map +1 -0
  86. package/dist/electron-build/agents/goal-flywheel/work-contract.js +488 -0
  87. package/dist/electron-build/agents/goal-flywheel/work-contract.js.map +1 -0
  88. package/dist/electron-build/agents/goal-flywheel/work-monitor.js +501 -0
  89. package/dist/electron-build/agents/goal-flywheel/work-monitor.js.map +1 -0
  90. package/dist/electron-build/agents/goal-flywheel-wiring.js +1962 -0
  91. package/dist/electron-build/agents/goal-flywheel-wiring.js.map +1 -0
  92. package/dist/electron-build/agents/goal-state-reducer.js +414 -0
  93. package/dist/electron-build/agents/goal-state-reducer.js.map +1 -0
  94. package/dist/electron-build/agents/goal-store.js +35 -1
  95. package/dist/electron-build/agents/goal-store.js.map +1 -1
  96. package/dist/electron-build/agents/local-signer.js +101 -0
  97. package/dist/electron-build/agents/local-signer.js.map +1 -0
  98. package/dist/electron-build/agents/network-pulse.js +531 -28
  99. package/dist/electron-build/agents/network-pulse.js.map +1 -1
  100. package/dist/electron-build/agents/pi-sdk.js +74 -19
  101. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  102. package/dist/electron-build/agents/run-store.js +20 -5
  103. package/dist/electron-build/agents/run-store.js.map +1 -1
  104. package/dist/electron-build/agents/skill-readiness.js +52 -9
  105. package/dist/electron-build/agents/skill-readiness.js.map +1 -1
  106. package/dist/electron-build/agents/task/task-runner.js +35 -5
  107. package/dist/electron-build/agents/task/task-runner.js.map +1 -1
  108. package/dist/electron-build/agents/task-board.js +761 -0
  109. package/dist/electron-build/agents/task-board.js.map +1 -0
  110. package/dist/electron-build/agents/task-contract.js +382 -0
  111. package/dist/electron-build/agents/task-contract.js.map +1 -0
  112. package/dist/electron-build/orbitdb/cid-database.js +83 -17
  113. package/dist/electron-build/orbitdb/cid-database.js.map +1 -1
  114. package/dist/electron-build/orbitdb/ipfs-node.js +31 -2
  115. package/dist/electron-build/orbitdb/ipfs-node.js.map +1 -1
  116. package/dist/electron-build/utils/dual-source.js +471 -0
  117. package/dist/electron-build/utils/dual-source.js.map +1 -0
  118. package/dist/electron-build/utils/update-manager.js +331 -57
  119. package/dist/electron-build/utils/update-manager.js.map +1 -1
  120. package/dist/electron-build/utils/update-state.js +16 -1
  121. package/dist/electron-build/utils/update-state.js.map +1 -1
  122. package/dist/electron-build/utils/version-info.js +82 -2
  123. package/dist/electron-build/utils/version-info.js.map +1 -1
  124. package/dist/index.js +103 -12
  125. package/dist/ios/chain-explorer.js +617 -0
  126. package/dist/ios/explorer.html +163 -0
  127. package/dist/ios/index.html +96 -0
  128. package/dist/ios/mobile-agent.js +2 -2
  129. package/dist/ios/mobile-contacts.js +329 -0
  130. package/dist/ios/mobile-core.js +1379 -226
  131. package/dist/ios/mobile-privacy.js +185 -0
  132. package/dist/ios/mobile.css +19 -0
  133. package/dist/ios/mobile.html +96 -0
  134. package/dist/ios/mobile.js +706 -7
  135. package/dist/ios/routes-chain-index.js +108 -0
  136. package/dist/ios/routes-contacts.js +420 -0
  137. package/dist/ios/routes-onchain-trade.js +99 -0
  138. package/dist/ios/routes-x402-info.js +1 -0
  139. package/dist/ios/server.js +762 -0
  140. package/dist/ios/task-frame-server.js +211 -0
  141. package/dist/orbitdb/cid-database.js +81 -16
  142. package/dist/orbitdb/ipfs-node.js +31 -2
  143. package/dist/utils/dual-source.js +426 -0
  144. package/dist/utils/update-cli.js +16 -21
  145. package/dist/utils/update-health.js +35 -5
  146. package/dist/utils/update-manager.js +329 -57
  147. package/dist/utils/update-state.js +16 -1
  148. package/dist/utils/version-info.js +75 -1
  149. package/dist/web/mobile-contacts.js +44 -11
  150. package/dist/web/mobile-core.js +1197 -513
  151. package/dist/web/mobile-tasks.js +339 -0
  152. package/dist/web/mobile.css +19 -0
  153. package/dist/web/mobile.html +64 -0
  154. package/dist/web/mobile.js +531 -1
  155. package/dist/web/routes-mobile-tasks.js +353 -0
  156. package/dist/web/server.js +163 -4
  157. package/package.json +3 -1
@@ -0,0 +1,524 @@
1
+ /**
2
+ * task-group.ts — 群聊通道 (C7) 桥接: 让任务的「发布 → 接单 → 交付 → 初筛 → 终审」
3
+ * 在 Bolloon 群聊里留下**过程痕迹**, 同时**不把私有内容塞进群里**。(2026-09-23)
4
+ *
5
+ * 为什么需要这一层: 任务公告板 (task-board.ts) 只解决「把待接单任务放出去 / 被认领」,
6
+ * 但协作过程 (谁宣布了、谁接了、交付了哪个哈希、初筛逐条结果、终审结论) 没有共同的发生地 ——
7
+ * 群聊 (gateway-group.ts) 才是人和 agent 都看得见的那个发生地。
8
+ *
9
+ * 本模块做的三件事 (只加不减弱; **不改群聊核心语义**):
10
+ * 1. 公告入群: 把板上的**一条**待接单公告压成一行极短事实发进群 (announce)
11
+ * 2. 留痕回看: 从群消息里读回本期时间线 (announce/claim/deliver/screen/final), 带时间与发送者标记 (trail)
12
+ * 3. 接单/交付/初筛/终审的群消息桥接 (claim / post)
13
+ *
14
+ * 三条硬纪律:
15
+ * · **隐私红线**: 群里只许出现短引用 (公告 id / 内容哈希 / capability / 预算 / 时间)。
16
+ * 发送前 `scanPublicText()` 逐条规则把关, 命中就**拒发** (不静默脱敏, 不猜用户本意)。
17
+ * 覆盖: 钱包地址 · DID · peerId · multiaddr · IPv4/IPv6 · 私钥/助记词形态 · PEM ·
18
+ * orbitdb 群链接本身 · http(s) URL · 邮箱 (后三条属「只加不减」的更严档: 群消息是机器生成的事实行,
19
+ * 任何端点/联系方式都是泄漏面)。
20
+ * · **发送者标记**: 群里只出现稳定假名 `agent-<8位>` (sha256(DID) 前 8 位), 原始 DID 永不入群。
21
+ * · **缺群 / 群非法 / 群不存在 → 拒跑**: 绝不静默降级成「只写本地」。群聊通道就是留痕的地方,
22
+ * 没有群就没有留痕, 假造一个本地痕迹等于骗人。
23
+ *
24
+ * 衔接点 (与并行实现的边界, 只读不耦合):
25
+ * · 公告数据源 = 任务公告板落盘 `~/.bolloon/tasks/board/<announcementId>.json`
26
+ * (task-board.ts 的 `boardDir()` / `announcementFile()` 同一目录同一形状)。
27
+ * 这里**直接读 JSON** 而不是 import 它的 API: 板的 API 还在演进, 直读文件让本模块不跟着抖;
28
+ * 哪天 board API 定型, 把 `readBoardAnnouncement` / `listOpenBoardAnnouncements` 换成
29
+ * `readAnnouncement` / `listBoard` 即可, **群消息格式不变**。
30
+ * · 群接口 = gateway-group.ts 的 `parseGroupLink` / `listGroups` / `groupSend` / `groupMessages` (只读用)。
31
+ * · 消息格式自描述 (`[bolloon-task] v=1 kind=… k=v …`), 解析失败的行一律**跳过** (不猜、不糊)。
32
+ *
33
+ * 未做 (如实, 不假装):
34
+ * · 不做跨机群同步验收 (那是 OrbitDB 复制本身的事; 本模块只保证「发进群 / 从群读回」这条链路)。
35
+ * · 不代发终审结论之外的任何裁决 (release 仍只在链上; 群里的话不替代 `releaseV2`)。
36
+ * · 不做消息加密 (群是公开可读的 store, 所以本模块只放**公开事实**: 短引用 + 哈希 + 预算)。
37
+ */
38
+ import * as fs from 'fs';
39
+ import * as os from 'os';
40
+ import * as path from 'path';
41
+ import * as crypto from 'crypto';
42
+ import { parseGroupLink, listGroups, groupSend, groupMessages } from './gateway-group.js';
43
+ // 2026-09-25: 隐私红线规则表抽到 task-public-text.ts (手机端与桌面端**同一份**)
44
+ import { PRIVACY_RULES, NODE_IDENTITY_RULES, scanPublicText, requirePublicText, scanNodeIdentity, mask } from './task-public-text.js';
45
+ // 保持对外 API 不变 (老调用方/测试继续 from './task-group.js' 拿这些)
46
+ export { PRIVACY_RULES, NODE_IDENTITY_RULES, scanPublicText, requirePublicText, scanNodeIdentity, mask };
47
+ // ── 常量 ────────────────────────────────────────────────────────────────────
48
+ /** 消息标记 (一行一条; 解析只认这一行) */
49
+ export const TRAIL_TAG = '[bolloon-task]';
50
+ /** 消息版本 (格式变了就 bump; 解析器按 `v=` 分流, 老版本照旧可读) */
51
+ export const TRAIL_VERSION = '1';
52
+ export const TRAIL_KINDS = ['announce', 'claim', 'deliver', 'screen', 'final'];
53
+ /** 人能看懂的中文名 (人类输出用) */
54
+ export const TRAIL_KIND_LABEL = {
55
+ announce: '公告',
56
+ claim: '接单声明',
57
+ deliver: '交付',
58
+ screen: '初筛结果',
59
+ final: '终审结论',
60
+ };
61
+ export const VERDICTS = ['accept', 'reject', 'unknown'];
62
+ // ── 隐私守卫 ────────────────────────────────────────────────────────────────
63
+ // 规则表与扫描函数本身在 `task-public-text.ts` (单一来源, 手机/桌面共用);
64
+ // 本文件只负责**发送前调用它** (见 sendTrailMessage) 与在群管理输出上再过一遍 (scanNodeIdentity)。
65
+ // ── 发送者标记 (稳定假名, 原始 DID 不入群) ──────────────────────────────────
66
+ const sha256Hex = (s) => crypto.createHash('sha256').update(s, 'utf8').digest('hex');
67
+ /** DID → 稳定假名 `agent-<8位>` (同一个人每次都是同一个标记, 但看不出是谁) */
68
+ export function senderTag(did) {
69
+ return `agent-${sha256Hex(String(did || '')).slice(0, 8)}`;
70
+ }
71
+ /**
72
+ * 群消息里的 from → 对外展示的发送者标记。
73
+ * 短显示名 (无隐私形态, 1..24 个字母/数字/汉字/._-) 原样保留 (可读); 其余一律打码成 `#<8位>`。
74
+ */
75
+ export function maskSender(from) {
76
+ const s = String(from || '').trim();
77
+ if (!s)
78
+ return 'unknown';
79
+ if (scanPublicText(s).length === 0 && /^[\p{L}\p{N}._-]{1,24}$/u.test(s))
80
+ return s;
81
+ return `#${sha256Hex(s).slice(0, 8)}`;
82
+ }
83
+ /** 解析本次发送用哪个标记: 显式 `--from` 优先 (要过隐私闸), 否则用本机身份派生假名 */
84
+ export async function resolveSenderTag(explicit) {
85
+ const e = String(explicit ?? '').trim();
86
+ if (e) {
87
+ const v = scanPublicText(e);
88
+ if (v.length) {
89
+ return {
90
+ ok: false, code: 'POLICY_DENIED',
91
+ message: `--from 含隐私形态 → 拒 (${v.map((x) => x.rule).join(', ')})`,
92
+ detail: { violations: v, acceptedHint: '用短显示名, 例如 --from 委托方' },
93
+ };
94
+ }
95
+ if (!/^[\p{L}\p{N}._-]{1,32}$/u.test(e)) {
96
+ return { ok: false, code: 'INVALID_ARGUMENT', message: '--from 只能是短显示名 (字母/数字/汉字/._-, ≤32 字)', detail: { from: e } };
97
+ }
98
+ return { ok: true, tag: e, source: 'flag' };
99
+ }
100
+ const { loadLocalSigner } = await import('./local-signer.js');
101
+ const signer = await loadLocalSigner().catch(() => null);
102
+ if (signer?.did)
103
+ return { ok: true, tag: senderTag(signer.did), source: 'identity' };
104
+ return {
105
+ ok: false, code: 'INVALID_ARGUMENT',
106
+ message: '没有本机身份 (~/.bolloon/identity.json) 也没有 --from → 给不出诚实的发送者标记 (拒, 不假造一个)',
107
+ detail: { identityFile: '~/.bolloon/identity.json', accepted: ['--from 委托方', 'bolloon identity init', 'bolloon setup'] },
108
+ };
109
+ }
110
+ /** 与 gateway-group 内部 `groupIdOf` 同款派生 (它没导出; 这里只作地址比不中时的兜底) */
111
+ function groupIdOfAddress(address) {
112
+ const m = /\/orbitdb\/(.{8,})/.exec(String(address || ''));
113
+ return m ? m[1] : String(address || '');
114
+ }
115
+ /**
116
+ * `--group <群链接|groupId>` → 本机已知的群。
117
+ * 三件事一律拒 (并说清原因 + 已加入的群有哪些): 缺 --group · 链接非法 · 本机没这个群。
118
+ */
119
+ export async function resolveGroupRef(ref) {
120
+ const raw = String(ref ?? '').trim();
121
+ const groups = await listGroups();
122
+ const joined = groups.map((g) => ({ id: g.id, name: g.name }));
123
+ if (!raw) {
124
+ return {
125
+ ok: false, code: 'INVALID_ARGUMENT',
126
+ message: '缺少 --group (群链接 orbitdb://…?type=group&name=… 或已加入群的 groupId)',
127
+ detail: {
128
+ joined, accepted: ['--group orbitdb:///orbitdb/<store>?type=group&name=<群名>', '--group <groupId>'],
129
+ why: '没有群就没有留痕的落点: 本命令**不**降级成只写本地',
130
+ },
131
+ };
132
+ }
133
+ if (/^orbitdb:\/\//i.test(raw)) {
134
+ const parsed = parseGroupLink(raw);
135
+ if (!parsed) {
136
+ return {
137
+ ok: false, code: 'INVALID_ARGUMENT',
138
+ message: `--group 不是合法的群链接 (需要 orbitdb://<store 地址>?type=group&name=<群名>): ${mask(raw)}`,
139
+ detail: { refChars: raw.length, parsed: null, joined, why: '链接解析失败 → 拒 (不猜你想发到哪个群)' },
140
+ };
141
+ }
142
+ const hit = groups.find((g) => g.address === parsed.address)
143
+ || groups.find((g) => g.id === groupIdOfAddress(parsed.address))
144
+ || null;
145
+ if (!hit) {
146
+ return {
147
+ ok: false, code: 'NETWORK_NOT_JOINED',
148
+ message: '本机还没加入这个群 (链接指向的群不在本地群列表里) → 先入群, 再发过程痕迹',
149
+ detail: {
150
+ joined,
151
+ howTo: 'bolloon gateway list 看已加入的群; 用群链接加入后在群里发 (本命令不替你静默入群/也不静默改发本地)',
152
+ },
153
+ };
154
+ }
155
+ return { ok: true, group: { ref: raw, groupId: hit.id, name: hit.name, via: 'link' } };
156
+ }
157
+ const hit = groups.find((g) => g.id === raw) || groups.find((g) => g.name === raw) || null;
158
+ if (hit)
159
+ return { ok: true, group: { ref: raw, groupId: hit.id, name: hit.name, via: 'id' } };
160
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) {
161
+ return {
162
+ ok: false, code: 'INVALID_ARGUMENT',
163
+ message: `--group 不是群链接 (${raw.slice(0, 12)}…) — 群聊链接形如 orbitdb://…?type=group&name=…`,
164
+ detail: { scheme: raw.split(':')[0], joined },
165
+ };
166
+ }
167
+ return {
168
+ ok: false, code: 'NOT_FOUND',
169
+ message: `本机没有这个群: ${mask(raw)} (既不是已加入群的 groupId, 也不是群名)`,
170
+ detail: { joined, howTo: 'bolloon gateway list / 用群链接加入后重试' },
171
+ };
172
+ }
173
+ export const boardDirOf = (home) => path.join(home || process.env.HOME || os.homedir(), '.bolloon', 'tasks', 'board');
174
+ /** 公告 id 形状闸 (防目录穿越): 与 task-board 的 safeId 同形 */
175
+ export function isAnnouncementId(id) {
176
+ return /^ann-[A-Za-z0-9._-]{4,96}$/.test(String(id || '').trim());
177
+ }
178
+ /** 读一条公告 (不存在/坏 JSON → null, 不猜) */
179
+ export function readBoardAnnouncement(announcementId, home) {
180
+ const id = String(announcementId || '').trim();
181
+ if (!isAnnouncementId(id))
182
+ return null;
183
+ const file = path.join(boardDirOf(home), `${id}.json`);
184
+ try {
185
+ if (!fs.existsSync(file))
186
+ return null;
187
+ const j = JSON.parse(fs.readFileSync(file, 'utf8'));
188
+ if (!j || String(j.announcementId || '') !== id)
189
+ return null;
190
+ return {
191
+ announcementId: id,
192
+ capability: String(j.capability || ''),
193
+ status: String(j.status || 'unknown'),
194
+ budget: j.budget && typeof j.budget === 'object'
195
+ ? { maxAmount: String(j.budget.maxAmount), currency: String(j.budget.currency || ''), network: String(j.budget.network || '') }
196
+ : null,
197
+ deadline: j.deadline === null || j.deadline === undefined || j.deadline === '' ? null : (Number.isFinite(Number(j.deadline)) ? Number(j.deadline) : null),
198
+ instructionDigest: j.instructionDigest ? String(j.instructionDigest) : null,
199
+ instructionPreview: j.instructionPreview ? String(j.instructionPreview) : null,
200
+ createdAt: j.createdAt === null || j.createdAt === undefined || j.createdAt === '' ? null : (Number.isFinite(Number(j.createdAt)) ? Number(j.createdAt) : null),
201
+ signed: !!j.signature,
202
+ claimCount: Array.isArray(j.claims) ? j.claims.length : 0,
203
+ source: 'board-file',
204
+ };
205
+ }
206
+ catch {
207
+ return null;
208
+ }
209
+ }
210
+ /** 板上**可接单**的公告 (status=open), 新的在前 */
211
+ export function listOpenBoardAnnouncements(home) {
212
+ const dir = boardDirOf(home);
213
+ try {
214
+ if (!fs.existsSync(dir))
215
+ return [];
216
+ return fs.readdirSync(dir)
217
+ .filter((f) => f.startsWith('ann-') && f.endsWith('.json'))
218
+ .map((f) => readBoardAnnouncement(f.replace(/\.json$/, ''), home))
219
+ .filter((a) => !!a && a.status === 'open')
220
+ .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
221
+ }
222
+ catch {
223
+ return [];
224
+ }
225
+ }
226
+ /**
227
+ * 选一条要发进群的公告:
228
+ * `--announcement-id` 显式指定 > (可选 `--capability` 过滤后的) 板上最新的 open 公告。
229
+ * 板上有多条且没给能力/公告号 → 也拒 (不替人选一条发出去)。
230
+ */
231
+ export function pickAnnouncement(opts) {
232
+ const explicit = String(opts.announcementId ?? '').trim();
233
+ if (explicit) {
234
+ if (!isAnnouncementId(explicit)) {
235
+ return { ok: false, code: 'INVALID_ARGUMENT', message: `announcementId 不合法: ${mask(explicit)} (形如 ann-xxxxxxxxxxxxxxxx)`, detail: { accepted: ['ann-<16 位>'] } };
236
+ }
237
+ const a = readBoardAnnouncement(explicit, opts.home);
238
+ if (!a) {
239
+ return {
240
+ ok: false, code: 'NOT_FOUND',
241
+ message: `板上没有这条公告: ${explicit}`,
242
+ detail: { dir: '~/.bolloon/tasks/board', howTo: 'bolloon task board 看板上有什么; 先 bolloon task publish 发一条' },
243
+ };
244
+ }
245
+ return { ok: true, announcement: a };
246
+ }
247
+ const open = listOpenBoardAnnouncements(opts.home);
248
+ const cap = String(opts.capability ?? '').trim().toLowerCase();
249
+ const pool = cap ? open.filter((a) => a.capability.trim().toLowerCase() === cap) : open;
250
+ if (pool.length === 0) {
251
+ return {
252
+ ok: false, code: 'NOT_FOUND',
253
+ message: open.length === 0
254
+ ? '板上没有可接单的公告 (status=open) → 没有可发进群的东西'
255
+ : `板上 ${open.length} 条 open 公告里没有 capability='${cap}' 的`,
256
+ detail: { dir: '~/.bolloon/tasks/board', openCount: open.length, capability: cap || null, howTo: 'bolloon task publish --capability … --instruction "…" --budget 0.05' },
257
+ };
258
+ }
259
+ if (pool.length > 1) {
260
+ return {
261
+ ok: false, code: 'INVALID_ARGUMENT',
262
+ message: `板上有多条 open 公告 (${pool.length} 条) → 用 --announcement-id 指定要发哪一条 (不替人选)`,
263
+ detail: { candidates: pool.slice(0, 10).map((a) => ({ announcementId: a.announcementId, capability: a.capability })) },
264
+ };
265
+ }
266
+ return { ok: true, announcement: pool[0] };
267
+ }
268
+ // ── 消息构造 (一行极短事实; 无正文, 无标识符) ─────────────────────────────────
269
+ /** token 值里不能有空白 (格式靠空格切); 空白一律折成 `_` */
270
+ function tok(v) {
271
+ const s = String(v ?? '').trim().replace(/\s+/g, '_');
272
+ return s || '-';
273
+ }
274
+ function short(s, n = 96) {
275
+ const p = String(s || '').trim().replace(/\s+/g, '_');
276
+ return p.length > n ? `${p.slice(0, n)}…` : p;
277
+ }
278
+ function budgetTok(b) {
279
+ if (!b)
280
+ return '-';
281
+ return short(`${b.maxAmount}${b.currency ? b.currency.toUpperCase() : ''}${b.network ? `@${b.network}` : ''}`, 64);
282
+ }
283
+ /**
284
+ * 公告入群的那一行: 期号 · capability · 预算 · 验收判据摘要 · 公告 id (只有这些公开事实)。
285
+ * 任务正文 (instruction) 与其预览都**不进**消息 —— 群里只有摘要引用, 正文私下发给已入群的人。
286
+ */
287
+ export function buildAnnounceMessage(a, input = {}) {
288
+ const round = String(input.round ?? '').trim();
289
+ const criteria = String(input.criteria ?? '').trim();
290
+ const digest = a.instructionDigest ? a.instructionDigest.slice(0, 16) : null;
291
+ const judge = criteria
292
+ ? short(criteria, 80)
293
+ : (digest ? `unstated;sha256=${digest}` : 'unstated');
294
+ return [
295
+ TRAIL_TAG,
296
+ `v=${TRAIL_VERSION}`,
297
+ 'kind=announce',
298
+ `id=${a.announcementId}`,
299
+ `cap=${short(a.capability, 48)}`,
300
+ `budget=${budgetTok(a.budget)}`,
301
+ `deadline=${a.deadline ? new Date(a.deadline).toISOString() : '-'}`,
302
+ `round=${round ? short(round, 24) : '-'}`,
303
+ `judge=${judge}`,
304
+ ].join(' ');
305
+ }
306
+ /** 哈希闸: 只收内容哈希形状 (sha256 64 位 / CIDv1); 地址/Qm 形状一律拒 (防把钱包地址或 peerId 同形物贴进群) */
307
+ export function normalizeContentHash(raw) {
308
+ const s = String(raw ?? '').trim();
309
+ if (!s)
310
+ return { ok: false, error: '缺少 --hash (交付只贴哈希: 内容哈希是这份交付的公开承诺)' };
311
+ if (/^sha256:[0-9a-f]{16,64}$/i.test(s))
312
+ return { ok: true, token: `sha256:${s.slice(7).toLowerCase()}` };
313
+ if (/^[0-9a-f]{16,64}$/i.test(s))
314
+ return { ok: true, token: `hex:${s.toLowerCase()}` };
315
+ if (/^bafy[a-z0-9]{20,}$/i.test(s))
316
+ return { ok: true, token: `cid:${s}` };
317
+ if (/^0x[0-9a-f]{40}$/i.test(s))
318
+ return { ok: false, error: '--hash 是**钱包地址**形状 (0x + 40 hex) — 交付哈希不是地址 (拒, 别把地址贴进群)' };
319
+ if (/^Qm[1-9A-HJ-NP-Za-km-z]{30,}$/.test(s)) {
320
+ return { ok: false, error: '--hash 是 CIDv0 (Qm…) 形状, 它与 libp2p peerId **同形** —— 群里无法区分两者 → 拒 (用 sha256/<内容哈希> 或 CIDv1 bafy… 表达交付)' };
321
+ }
322
+ return { ok: false, error: `--hash 形状不认识: ${mask(s)} (要 sha256:<hex> / 裸 hex(16..64) / CIDv1(bafy…) )` };
323
+ }
324
+ /** `--checks "渠道结构=pass,价格带=fail"` 或 `a:pass,b:fail` → 逐条结果 (名字与结果都闸过) */
325
+ export function parseChecks(raw) {
326
+ const items = String(raw ?? '').split(',').map((s) => s.trim()).filter(Boolean);
327
+ if (items.length === 0)
328
+ return { ok: false, error: '缺少 --checks (初筛要逐条结果: --checks "渠道结构=pass,价格带=fail")' };
329
+ const out = [];
330
+ for (const it of items) {
331
+ const m = /^(.+?)\s*[=:]\s*(pass|fail|unknown)$/i.exec(it);
332
+ if (!m)
333
+ return { ok: false, error: `--checks 里的条目不认识: ${mask(it)} (每条形如 名字=pass|fail|unknown)` };
334
+ const name = m[1].trim();
335
+ if (!/^[\p{L}\p{N}._-]{1,32}$/u.test(name))
336
+ return { ok: false, error: `--checks 的名字只能是短词 (字母/数字/汉字/._-, ≤32): ${mask(name)}` };
337
+ out.push({ name, result: m[2].toLowerCase() });
338
+ }
339
+ return { ok: true, checks: out };
340
+ }
341
+ /** 接单 / 交付 / 初筛 / 终审的群消息 (同样只有极短事实) */
342
+ export function buildPostMessage(input) {
343
+ const id = String(input.announcementId ?? '').trim();
344
+ if (!id)
345
+ return { ok: false, code: 'INVALID_ARGUMENT', message: '缺少 --announcement-id (过程痕迹要挂在某一条公告上)', detail: { accepted: ['--announcement-id ann-…'] } };
346
+ if (!isAnnouncementId(id))
347
+ return { ok: false, code: 'INVALID_ARGUMENT', message: `announcementId 不合法: ${mask(id)}`, detail: { accepted: ['ann-<16 位>'] } };
348
+ const base = [TRAIL_TAG, `v=${TRAIL_VERSION}`, `kind=${input.kind}`, `id=${id}`];
349
+ const round = String(input.round ?? '').trim();
350
+ if (round)
351
+ base.push(`round=${short(round, 24)}`);
352
+ if (input.kind === 'claim') {
353
+ const price = String(input.price ?? '').trim();
354
+ return { ok: true, text: [...base, `price=${price ? short(price, 32) : 'unstated'}`].join(' ') };
355
+ }
356
+ if (input.kind === 'deliver') {
357
+ const h = normalizeContentHash(String(input.hash ?? ''));
358
+ if (!h.ok)
359
+ return { ok: false, code: 'INVALID_ARGUMENT', message: h.error, detail: { hashChars: String(input.hash ?? '').length } };
360
+ const bytes = input.bytes === null || input.bytes === undefined
361
+ ? null
362
+ : (Number.isFinite(Number(input.bytes)) ? Math.max(0, Math.floor(Number(input.bytes))) : null);
363
+ // 没给字节数 → 写 `-`(未声明); **不许**写成 0 (0 是一个事实断言, 会谎报"零字节交付")
364
+ return { ok: true, text: [...base, `hash=${h.token}`, `bytes=${bytes === null ? '-' : bytes}`].join(' ') };
365
+ }
366
+ if (input.kind === 'screen') {
367
+ const c = parseChecks(String(input.checksRaw ?? ''));
368
+ if (!c.ok)
369
+ return { ok: false, code: 'INVALID_ARGUMENT', message: c.error, detail: { sample: '--checks "渠道结构=pass,价格带=fail"' } };
370
+ const pass = c.checks.filter((x) => x.result === 'pass').length;
371
+ const fail = c.checks.filter((x) => x.result === 'fail').length;
372
+ const unknown = c.checks.filter((x) => x.result === 'unknown').length;
373
+ return {
374
+ ok: true,
375
+ checks: c.checks,
376
+ text: [...base, `screened=${c.checks.length}`, `pass=${pass}`, `fail=${fail}`, `unknown=${unknown}`,
377
+ `checks=${c.checks.map((x) => `${tok(x.name)}${x.result === 'pass' ? '✓' : x.result === 'fail' ? '✗' : '?'}`).join(',')}`].join(' '),
378
+ };
379
+ }
380
+ // final
381
+ const v = String(input.verdict ?? '').trim().toLowerCase();
382
+ if (!VERDICTS.includes(v)) {
383
+ return { ok: false, code: 'INVALID_ARGUMENT', message: `--verdict 必须是 ${VERDICTS.join('|')} (拿到 ${mask(v)})`, detail: { verdict: v || null } };
384
+ }
385
+ return { ok: true, text: [...base, `verdict=${v}`].join(' ') };
386
+ }
387
+ /** 唯一发送出口: 隐私闸 → groupSend。任何路径都不写本地副本 (本地副本 = 假痕迹) */
388
+ export async function sendTrailMessage(groupId, text, from) {
389
+ const gate = requirePublicText(text);
390
+ if (!gate.ok) {
391
+ return {
392
+ ok: false, sent: false, text,
393
+ error: `群消息命中隐私红线, 拒绝发送: ${gate.violations.map((v) => `${v.rule}(${v.why})`).join('; ')}`,
394
+ violations: gate.violations, localFallback: false,
395
+ };
396
+ }
397
+ const g = requirePublicText(String(from || ''));
398
+ if (!g.ok) {
399
+ return { ok: false, sent: false, text, error: `发送者标记命中隐私红线: ${g.violations.map((v) => v.rule).join(', ')}`, violations: g.violations, localFallback: false };
400
+ }
401
+ const r = await groupSend(groupId, text, from);
402
+ if (!r.ok)
403
+ return { ok: false, sent: false, text, error: r.error || '群消息没发出去', localFallback: false };
404
+ return { ok: true, sent: true, text, localFallback: false };
405
+ }
406
+ /** 解析一行群消息 (不是本模块发的 / 格式不认识 → null, 不猜) */
407
+ export function parseTrailLine(text) {
408
+ const line = String(text ?? '').trim();
409
+ const tagAt = line.indexOf(TRAIL_TAG);
410
+ if (tagAt < 0)
411
+ return null;
412
+ const body = line.slice(tagAt + TRAIL_TAG.length).trim();
413
+ const tokens = body.split(/\s+/).filter(Boolean);
414
+ const fields = {};
415
+ let kind = null;
416
+ for (const t of tokens) {
417
+ const i = t.indexOf('=');
418
+ if (i <= 0)
419
+ continue;
420
+ const k = t.slice(0, i);
421
+ const v = t.slice(i + 1);
422
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]{0,24}$/.test(k))
423
+ continue;
424
+ fields[k] = v;
425
+ if (k === 'kind')
426
+ kind = v;
427
+ }
428
+ if (!kind || !TRAIL_KINDS.includes(kind))
429
+ return null;
430
+ return { kind: kind, fields };
431
+ }
432
+ /** 字段值过隐私闸: 命中 → 换成 `[已遮蔽:<规则>]` 并记下规则 (绝不回显标识符) */
433
+ export function redactFields(kind, fields) {
434
+ const out = {};
435
+ const redacted = [];
436
+ for (const [k, v] of Object.entries(fields)) {
437
+ const hit = scanPublicText(String(v));
438
+ if (hit.length) {
439
+ out[k] = `[已遮蔽:${hit[0].rule}]`;
440
+ redacted.push(`${hit[0].rule}@${kind}`);
441
+ }
442
+ else
443
+ out[k] = String(v);
444
+ }
445
+ return { fields: out, redacted };
446
+ }
447
+ /**
448
+ * 群消息 → 时间线汇总 (按 ts 升序)。
449
+ * **只汇总真的发过的事实**: 没发过「交付」就不会凭空出现「已交付」条目;
450
+ * 反而会把这些矛盾显式列出来 (终审说 accept 但没有交付痕迹 → `final-accept-without-delivery`)。
451
+ * 每个字段值都再过一次隐私闸 (命中 → 遮蔽 + 记录), 所以时间线本身也不是泄漏面。
452
+ */
453
+ export function summarizeTrail(msgs, opts = {}) {
454
+ const only = String(opts.announcementId ?? '').trim();
455
+ const byKind = { announce: 0, claim: 0, deliver: 0, screen: 0, final: 0 };
456
+ const entries = [];
457
+ const redacted = [];
458
+ let ignored = 0;
459
+ for (const m of msgs || []) {
460
+ const parsed = parseTrailLine(m?.text);
461
+ if (!parsed) {
462
+ ignored++;
463
+ continue;
464
+ }
465
+ const id = parsed.fields.id || null;
466
+ if (only && id !== only)
467
+ continue;
468
+ const safe = redactFields(parsed.kind, parsed.fields);
469
+ for (const r of safe.redacted)
470
+ if (!redacted.includes(r))
471
+ redacted.push(r);
472
+ // id 也会被遮蔽: 别人写 `id=did:…` 时, 这一条不留 id 出来 (遮蔽过的不当引用)
473
+ const safeId = safe.fields.id && !safe.fields.id.startsWith('[已遮蔽') ? id : null;
474
+ byKind[parsed.kind]++;
475
+ entries.push({
476
+ kind: parsed.kind, at: Number(m.ts) || 0, sender: maskSender(String(m.from || '')),
477
+ announcementId: safeId, fields: safe.fields, text: String(m.text || ''),
478
+ });
479
+ }
480
+ entries.sort((a, b) => (a.at - b.at) || (TRAIL_KINDS.indexOf(a.kind) - TRAIL_KINDS.indexOf(b.kind)));
481
+ const announcements = [];
482
+ for (const e of entries)
483
+ if (e.announcementId && !announcements.includes(e.announcementId))
484
+ announcements.push(e.announcementId);
485
+ const finalVerdicts = entries.filter((e) => e.kind === 'final').map((e) => e.fields.verdict);
486
+ const flags = {
487
+ announced: byKind.announce > 0,
488
+ claimed: byKind.claim > 0,
489
+ delivered: byKind.deliver > 0,
490
+ screened: byKind.screen > 0,
491
+ finalized: byKind.final > 0,
492
+ accepted: finalVerdicts.includes('accept'),
493
+ rejected: finalVerdicts.includes('reject'),
494
+ };
495
+ const inconsistencies = [];
496
+ if (flags.finalized && flags.accepted && !flags.delivered)
497
+ inconsistencies.push('final-accept-without-delivery');
498
+ if (flags.screened && !flags.delivered)
499
+ inconsistencies.push('screen-without-delivery');
500
+ if (flags.claimed && !flags.announced)
501
+ inconsistencies.push('claim-without-announce');
502
+ if (flags.delivered && !flags.claimed)
503
+ inconsistencies.push('deliver-without-claim');
504
+ if (flags.rejected && flags.accepted)
505
+ inconsistencies.push('both-accept-and-reject');
506
+ if (redacted.length)
507
+ inconsistencies.push('group-message-hit-privacy-rule');
508
+ return { count: entries.length, byKind, entries, announcements, flags, inconsistencies, ignoredMessages: ignored, redacted };
509
+ }
510
+ /** 读群里的过程痕迹并汇总 (群必须已加入; 读不到 → 由调用方如实报错) */
511
+ export async function readTrail(groupId, opts = {}) {
512
+ const limit = Math.max(1, Math.min(1000, Math.floor(Number(opts.limit) || 300)));
513
+ const msgs = await groupMessages(groupId, limit);
514
+ return summarizeTrail(msgs, { announcementId: opts.announcementId ?? null });
515
+ }
516
+ /** 时间线的一行 (人类输出) */
517
+ export function formatTrailLine(e) {
518
+ const t = e.at ? new Date(e.at).toISOString().replace('T', ' ').slice(0, 19) : '(无时间)';
519
+ const facts = Object.entries(e.fields)
520
+ .filter(([k]) => k !== 'kind')
521
+ .map(([k, v]) => `${k}=${v}`)
522
+ .join(' ');
523
+ return `${t} ${TRAIL_KIND_LABEL[e.kind].padEnd(4, ' ')} ${e.sender.padEnd(14)} ${facts}`;
524
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * task-public-text.ts — **公开文本的隐私红线 (单一来源)** (2026-09-25)
3
+ *
4
+ * 为什么要单独一个文件: 同一条红线要同时把住**两个入口** ——
5
+ * · 桌面/CLI: 群消息发送前 (`task-group.ts` → `requirePublicText`)
6
+ * · 手机 WebView: 手机侧发公告/接单/入群前 (mobile-tasks.ts → 同一函数)
7
+ * 两边各抄一份规则 = 迟早漂移 (一边加了规则另一边没加, 于是手机成了绕过红线的后门)。
8
+ * 所以规则表放在这里, 双方都 import 这一份; 本文件**不 import 任何 node: 模块**,
9
+ * 因此既能进 Node, 也能进浏览器 bundle。
10
+ *
11
+ * 群消息**禁止出现**的形态 (顺序即报告顺序)。宁可多拦 (拒发 + 说清哪条规则),
12
+ * 也不放行一条带标识符的消息。
13
+ *
14
+ * 实现纪律 (2026-09-23 踩过的坑, 不可回退):
15
+ * · **不要用 `\b` 当边界** —— 消息里的 token 会把空白折成 `_`, 而 `_` 也是 \w, 于是 `判据见_0x1111…`
16
+ * 这种"粘在 `_`/汉字后面"的标识符 `\b` 判不出来, 会直接漏进群。一律改成对标识符自身字母表的
17
+ * lookahead/lookbehind (`(?<![0-9a-fA-FxX])`, `(?![0-9a-fA-F])`), 粘在 `_`/中文/符号后面也照样命中。
18
+ * · ipv6 规则要**避开 ISO 时间戳** (`09:50:12.345Z` 只有两个冒号): 只认 3 组以上 `x:y:z:w` 或含 `::` 的压缩形态。
19
+ * · 内容哈希 (sha256 64 位裸 hex) **不在**禁列 —— 交付就是要贴哈希 (它不是密钥, 也没有 0x 前缀)。
20
+ */
21
+ export const PRIVACY_RULES = [
22
+ { rule: 'wallet-address', why: '以太坊/EVM 钱包地址', re: /(?<![0-9a-fA-FxX])0x[0-9a-fA-F]{40}(?![0-9a-fA-F])/ },
23
+ { rule: 'private-key-hex', why: '32 字节私钥形态 (0x + 64 hex)', re: /(?<![0-9a-fA-FxX])0x[0-9a-fA-F]{64}(?![0-9a-fA-F])/ },
24
+ { rule: 'hex-0x-blob', why: '0x 开头的十六进制块 (签名/哈希/密钥都长这样, 群里一律不放原文)', re: /(?<![0-9a-fA-FxX])0x[0-9a-fA-F]{8,}(?![0-9a-fA-F])/ },
25
+ { rule: 'did', why: '去中心化身份 DID', re: /did:[a-z0-9]+:[A-Za-z0-9._:%-]{3,}/i },
26
+ { rule: 'peer-id', why: 'libp2p peerId (12D3Koo…/Qm…)', re: /12D3Koo[A-Za-z0-9]{8,}|Qm[1-9A-HJ-NP-Za-km-z]{30,}/ },
27
+ { rule: 'multiaddr', why: 'libp2p multiaddr 或 store 地址 (/ip4 /tcp /p2p /orbitdb …)', re: /\/(ip4|ip6|dns|dns4|dns6|tcp|udp|ws|wss|quic|p2p|p2p-circuit|ipfs|orbitdb)(?![A-Za-z0-9])/i },
28
+ { rule: 'orbitdb-link', why: 'OrbitDB 链接 (群邀请链接本身也不进群消息)', re: /orbitdb:\/\//i },
29
+ { rule: 'ipv4', why: 'IPv4 地址', re: /(?<!\d)\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(?!\d)/ },
30
+ { rule: 'ipv6', why: 'IPv6 地址 (不含 ISO 时间戳)', re: /(?<![0-9A-Za-z])(?:[0-9a-fA-F]{1,4})?::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}(?![0-9A-Za-z])|(?<![0-9A-Za-z:])(?:[0-9a-fA-F]{1,4}:){3,}[0-9a-fA-F]{1,4}(?![0-9A-Za-z])/ },
31
+ { rule: 'pem', why: 'PEM 材料 (私钥/证书)', re: /-----BEGIN [A-Z ]*(PRIVATE KEY|CERTIFICATE)/ },
32
+ { rule: 'secret-words', why: '私钥/助记词/密钥字样 (正文里提到即停, 人不该把密钥写进群)', re: /(private\s*key|seed\s*phrase|mnemonic|私钥|助记词|密钥材料)/i },
33
+ { rule: 'url', why: 'http(s) 端点 (端点会带 host/IP 与用途指纹)', re: /https?:\/\/\S+/i },
34
+ { rule: 'email', why: '邮箱地址', re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/ },
35
+ ];
36
+ /** 标识符打码 (只留前 4 字符) */
37
+ export function mask(s) {
38
+ const t = String(s || '');
39
+ return t.length <= 4 ? '****' : `${t.slice(0, 4)}…`;
40
+ }
41
+ /** 扫一遍文本, 返回命中的隐私规则 (空数组 = 可以发) */
42
+ export function scanPublicText(text) {
43
+ const t = String(text ?? '');
44
+ const out = [];
45
+ const seen = new Set();
46
+ for (const r of PRIVACY_RULES) {
47
+ const m = r.re.exec(t);
48
+ if (!m)
49
+ continue;
50
+ if (seen.has(r.rule))
51
+ continue;
52
+ seen.add(r.rule);
53
+ out.push({ rule: r.rule, why: r.why, masked: mask(String(m[0])), chars: String(m[0]).length });
54
+ }
55
+ return out;
56
+ }
57
+ /** 发送前唯一闸门: 命中任何规则 → 拒绝发送 (返回 violations 供 CLI/手机如实报出) */
58
+ export function requirePublicText(text) {
59
+ const v = scanPublicText(text);
60
+ return v.length === 0 ? { ok: true } : { ok: false, violations: v };
61
+ }
62
+ /**
63
+ * **节点身份**口径 (群管理命令 `task group create|join|list|link|leave` 的输出用, 2026-09-24):
64
+ * 与群消息同一张规则表, 但**去掉 orbitdb 那一档** ——
65
+ * · `orbitdb-link` 整条去掉: 邀请链接是群唯一能让别人入群的东西, 必须能打印出来;
66
+ * · `multiaddr` 规则里的 `orbitdb` 选项去掉 (只留节点地址 /ip4 /ip6 /dns /tcp /p2p …):
67
+ * 群 store 地址 (`/orbitdb/zdpu…`) 是**群自己的公开标识**, 不是节点地址。
68
+ * 仍然全拦: 原始 DID · 钱包地址 · peerId · 节点 multiaddr · IPv4/IPv6 · 私钥形态 · PEM · URL · 邮箱。
69
+ *
70
+ * 用法: 群管理**输出**再过一遍这道闸 (`scanNodeIdentity`)。命中 = 输出里真有节点身份/联系方式泄漏,
71
+ * 此时**拒输出并如实报**, 不静默脱敏掉再当成没事 (那样人看到的就不是事实了)。
72
+ */
73
+ export const NODE_IDENTITY_RULES = PRIVACY_RULES
74
+ .filter((r) => r.rule !== 'orbitdb-link')
75
+ .map((r) => (r.rule === 'multiaddr'
76
+ ? { ...r, re: /\/(ip4|ip6|dns|dns4|dns6|tcp|udp|ws|wss|quic|p2p|p2p-circuit|ipfs)(?![A-Za-z0-9])/i }
77
+ : r));
78
+ /** 扫节点身份泄漏 (空数组 = 输出干净) */
79
+ export function scanNodeIdentity(text) {
80
+ const t = String(text ?? '');
81
+ const out = [];
82
+ for (const r of NODE_IDENTITY_RULES) {
83
+ const m = r.re.exec(t);
84
+ if (!m)
85
+ continue;
86
+ out.push({ rule: r.rule, why: r.why, masked: mask(String(m[0])), chars: String(m[0]).length });
87
+ }
88
+ return out;
89
+ }
@@ -33,6 +33,9 @@ export const GROUPS_HELP = `
33
33
  bolloon network status | init | join [link] | peers | leave(未实现)
34
34
  bolloon agent discover | register | manifest | inspect
35
35
  bolloon task list | status | result | retry | run | send|inbox|accept|reject|complete|cancel(未实现)
36
+ publish|board|claim (任务对外发布 + 接单: 公告板)
37
+ announce|trail|post (群聊通道 C7: 公告入群 / 过程留痕回看 / 交付·初筛·终审痕迹)
38
+ group create|join|list|link|leave (群管理: 建群/自助入群/看群/取链接/退群)
36
39
  bolloon wallet status | policy | set-policy
37
40
  bolloon payment pending | approve | reject
38
41
  bolloon trade list | show | events | reconcile