@jossuealcala/madre 0.7.1 → 1.0.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 (165) hide show
  1. package/CHANGELOG.md +833 -1
  2. package/README.md +92 -28
  3. package/SECURITY.md +1 -0
  4. package/bin/madre.mjs +30 -10
  5. package/docs/REFERENCE.md +158 -79
  6. package/docs/SDK.md +23 -19
  7. package/package.json +1 -1
  8. package/public/app.js +11599 -4186
  9. package/public/assets/brand/Geist-latin-ext.woff2 +0 -0
  10. package/public/assets/brand/Geist-latin.woff2 +0 -0
  11. package/public/assets/brand/GeistMono-latin-ext.woff2 +0 -0
  12. package/public/assets/brand/GeistMono-latin.woff2 +0 -0
  13. package/public/assets/brand/JetBrainsMono-latin-ext.woff2 +0 -0
  14. package/public/assets/brand/JetBrainsMono-latin.woff2 +0 -0
  15. package/public/assets/brand/OFL-Geist.txt +93 -0
  16. package/public/assets/brand/OFL-GeistMono.txt +93 -0
  17. package/public/assets/brand/OFL-JetBrainsMono.txt +93 -0
  18. package/public/assets/brand/OFL-Sora.txt +93 -0
  19. package/public/assets/brand/Sora-latin-ext.woff2 +0 -0
  20. package/public/assets/brand/Sora-latin.woff2 +0 -0
  21. package/public/assets/brand/madre-app-180.png +0 -0
  22. package/public/assets/brand/madre-app-192.png +0 -0
  23. package/public/assets/brand/madre-app-512.png +0 -0
  24. package/public/assets/brand/madre-app.svg +1 -0
  25. package/public/assets/brand/madre-favicon.svg +1 -0
  26. package/public/assets/news/1.0/alerts.webp +0 -0
  27. package/public/assets/news/1.0/bar.webp +0 -0
  28. package/public/assets/news/1.0/finder.webp +0 -0
  29. package/public/assets/news/1.0/github.webp +0 -0
  30. package/public/assets/news/1.0/intents.webp +0 -0
  31. package/public/assets/news/1.0/looks.webp +0 -0
  32. package/public/assets/news/1.0/modules.webp +0 -0
  33. package/public/assets/news/1.0/mother.webp +0 -0
  34. package/public/assets/news/1.0/nostromo.webp +0 -0
  35. package/public/assets/news/1.0/permissions.webp +0 -0
  36. package/public/assets/news/1.0/room.webp +0 -0
  37. package/public/assets/news/1.0/settings.webp +0 -0
  38. package/public/assets/wallpapers/biznaga.webp +0 -0
  39. package/public/assets/wallpapers/canela.webp +0 -0
  40. package/public/assets/wallpapers/jarrito.webp +0 -0
  41. package/public/assets/wallpapers/magna.webp +0 -0
  42. package/public/assets/wallpapers/oceano.webp +0 -0
  43. package/public/assets/wallpapers/paraiso.webp +0 -0
  44. package/public/assets/wallpapers/quila.webp +0 -0
  45. package/public/assets/wallpapers/tradicion.webp +0 -0
  46. package/public/assets/wallpapers/tropical.webp +0 -0
  47. package/public/assets/wallpapers/urbania.webp +0 -0
  48. package/public/assets/wallpapers/ventura.webp +0 -0
  49. package/public/assets/wallpapers/xico.webp +0 -0
  50. package/public/brands.js +28 -2
  51. package/public/editor.js +744 -0
  52. package/public/es.js +2863 -1008
  53. package/public/i18n.js +1 -1
  54. package/public/index.html +201 -175
  55. package/public/inquiry.js +11 -11
  56. package/public/loader.js +79 -0
  57. package/public/macroverso.js +1497 -0
  58. package/public/manifest.webmanifest +14 -0
  59. package/public/nostromo-engine.js +2222 -0
  60. package/public/resay.js +24 -0
  61. package/public/review-ask.js +85 -0
  62. package/public/salas-mac.css +787 -0
  63. package/public/salas-mac.html +154 -0
  64. package/public/salas-mac.js +3264 -0
  65. package/public/salas.css +85 -0
  66. package/public/salas.html +47 -0
  67. package/public/salas.js +200 -0
  68. package/public/styles.css +6303 -414
  69. package/public/touch.js +83 -0
  70. package/public/tour-chapters.js +342 -0
  71. package/public/tour.js +510 -0
  72. package/public/troubleshooting.js +159 -140
  73. package/src/adapters/claude.mjs +4 -3
  74. package/src/adapters/codex.mjs +49 -8
  75. package/src/adapters/gemini.mjs +30 -10
  76. package/src/adapters/madre.mjs +110 -18
  77. package/src/adapters/process.mjs +42 -2
  78. package/src/auth-probe.mjs +2 -2
  79. package/src/bridge/devices.mjs +202 -0
  80. package/src/bridge/handoff.mjs +45 -0
  81. package/src/bridge/host.mjs +82 -0
  82. package/src/bridge/network.mjs +133 -0
  83. package/src/bridge/policy.mjs +89 -0
  84. package/src/bridge/qr.mjs +287 -0
  85. package/src/bridge/server.mjs +269 -0
  86. package/src/capabilities.mjs +5 -9
  87. package/src/chats.mjs +59 -22
  88. package/src/checkpoint.mjs +137 -35
  89. package/src/choices.mjs +45 -6
  90. package/src/cli-args.mjs +12 -1
  91. package/src/commands.mjs +15 -15
  92. package/src/config.mjs +73 -9
  93. package/src/conversation-context.mjs +5 -2
  94. package/src/dataset.mjs +3 -1
  95. package/src/directives.mjs +0 -5
  96. package/src/enlaces.mjs +451 -0
  97. package/src/event-store.mjs +55 -10
  98. package/src/exam.mjs +5 -5
  99. package/src/extensions.mjs +0 -2
  100. package/src/file-ops.mjs +42 -0
  101. package/src/files.mjs +52 -2
  102. package/src/github.mjs +407 -0
  103. package/src/json-file.mjs +63 -0
  104. package/src/lease.mjs +2 -2
  105. package/src/lobby-work.mjs +103 -0
  106. package/src/looks.mjs +55 -0
  107. package/src/maturity.mjs +3 -3
  108. package/src/mcp/github-server.mjs +164 -0
  109. package/src/mcp/image-server.mjs +1 -1
  110. package/src/mcp/memory-server.mjs +11 -3
  111. package/src/memory.mjs +38 -12
  112. package/src/models.mjs +109 -1
  113. package/src/modules/ahp.mjs +4 -4
  114. package/src/modules/ash.mjs +1 -1
  115. package/src/modules/bishop-plan.mjs +207 -0
  116. package/src/modules/bishop.mjs +645 -0
  117. package/src/modules/choices.mjs +2 -2
  118. package/src/modules/correo.mjs +1 -1
  119. package/src/modules/enlaces.mjs +255 -0
  120. package/src/modules/git-pulse.mjs +2 -2
  121. package/src/modules/github.mjs +322 -0
  122. package/src/modules/image-studio.mjs +3 -3
  123. package/src/modules/index.mjs +19 -4
  124. package/src/modules/intents-calibration.mjs +338 -0
  125. package/src/modules/intents-desk.mjs +481 -0
  126. package/src/modules/intents-observe.mjs +266 -0
  127. package/src/modules/intents-policy.mjs +414 -0
  128. package/src/modules/intents-try.mjs +91 -0
  129. package/src/modules/intents.mjs +145 -0
  130. package/src/modules/ollama.mjs +9 -5
  131. package/src/modules/playwright.mjs +121 -11
  132. package/src/modules/ripley.mjs +1 -1
  133. package/src/modules/runs.mjs +34 -0
  134. package/src/modules/sdk.mjs +10 -4
  135. package/src/ollama.mjs +25 -1
  136. package/src/outbound.mjs +19 -17
  137. package/src/override.mjs +100 -0
  138. package/src/ports.mjs +23 -1
  139. package/src/prices.mjs +72 -0
  140. package/src/privacy.mjs +30 -11
  141. package/src/quota-sources.mjs +0 -8
  142. package/src/request-guard.mjs +1 -1
  143. package/src/review-ask.mjs +111 -0
  144. package/src/room/archivist.mjs +55 -37
  145. package/src/room/budget.mjs +17 -5
  146. package/src/room/control.mjs +88 -8
  147. package/src/room/economy.mjs +35 -7
  148. package/src/room/escalation.mjs +2 -0
  149. package/src/room/guard.mjs +26 -2
  150. package/src/room/prompt.mjs +32 -13
  151. package/src/room/runs.mjs +151 -0
  152. package/src/room/vectors.mjs +19 -4
  153. package/src/room.mjs +402 -54
  154. package/src/router.mjs +3 -1
  155. package/src/runs.mjs +137 -0
  156. package/src/salas.mjs +313 -0
  157. package/src/sentinel-errors.mjs +1 -1
  158. package/src/server-sentinel.mjs +124 -0
  159. package/src/server.mjs +1578 -167
  160. package/src/setup.mjs +1 -0
  161. package/src/tour.mjs +159 -0
  162. package/src/updates.mjs +48 -8
  163. package/src/usage-sentinel.mjs +1 -1
  164. package/src/verdict.mjs +7 -7
  165. package/src/whats-new.mjs +160 -0
@@ -0,0 +1,207 @@
1
+ // BISHOP's plans, without the room around them (docs/MACROVERSO.md, fases 4b y 4c): the block a
2
+ // reply ends with, what each of its lines means on this Mac, and the record the buttons act on.
3
+ // Nothing here sends, signs or starts anything; src/modules/bishop.mjs does, after a human press
4
+ // or within the mode the human chose.
5
+ import { randomBytes } from 'node:crypto';
6
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ import { LINK_KINDS, MAX_ORDER_MODE, receivesOrders } from '../enlaces.mjs';
9
+ import { salasRoomId } from '../salas.mjs';
10
+
11
+ export const MAX_ORDERS = 4;
12
+ export const MAX_LINKS = 2;
13
+ export const RESULT_CHARS = 1200;
14
+ export const ORDER_CHARS = 2000;
15
+ export const PLAN_FENCE = 'pulse-salas';
16
+ export const BISHOP_MODES = ['manual', 'hybrid', 'auto'];
17
+ const KEEP_PLANS = 40;
18
+ export const clean = (value, max = 60) => String(value ?? '').replace(/[\r\n\t<>]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
19
+
20
+ /* ---------- the headers a relayed message carries ---------- */
21
+
22
+ // What a turn reads first when BISHOP's channel brings it a message. English, like everything a
23
+ // turn reads, and with no word of @madre's list of actions (src/adapters/madre.mjs, ACTION_EN):
24
+ // «order» or «delegate» made @madre answer every order with its fixed refusal (S-031).
25
+ // Who sent it is said as it happened: pressed by the human, or on its own within the mode the human
26
+ // chose. Never «sent by the human» for an order nobody pressed.
27
+ export const orderHeader = ({ step, total, plan, auto = false }) => `BISHOP › ${step}/${total} · plan ${plan} · from Salas, ${auto ? 'sent on its own within the mode the human chose' : 'sent by the human'}. Answer in this room only; this turn cannot pass anything on to other rooms.`;
28
+ export const consultHeader = ({ room }) => `BISHOP › question from room ${clean(room, 60)}, asked by the human there. Answer from what you know of the linked rooms; this turn ends with the answer.`;
29
+ export const ORDER_HEADER = /^BISHOP › (\d+)\/(\d+) · plan (p-[0-9a-f]{6})\b/;
30
+ export const CONSULT_HEADER = /^BISHOP › question from room (.+?), asked by the human there\./;
31
+
32
+ /* ---------- the block ---------- */
33
+
34
+ // One order: `@<room>/<agent> #<0-4>: what to do`. The room is its name (it may have spaces) or
35
+ // its id; the agent is a crew id. The mode is a single digit and #3/#4 parse, to be refused later.
36
+ const ORDER_LINE = /^\s*[-*]?\s*@(.+?)\/([a-z][a-z0-9_-]*)\s*#(\d)\s*[::]\s*(.+)$/i;
37
+ // One link: `enlace <room> → <room>: decision, fact; 30 días; órdenes #1`. The arrow says which way
38
+ // notes travel (the first room hands over, the second reads); ↔ is both ways.
39
+ const LINK_LINE = /^\s*[-*]?\s*(?:enlace|link)\s*:?\s+@?(.+?)\s*(↔|<->|→|->)\s*@?(.+?)\s*[::]\s*(.+)$/i;
40
+ const KIND_WORDS = new Map([
41
+ ['decision', 'decision'], ['decisions', 'decision'], ['decisión', 'decision'], ['decision', 'decision'], ['decisiones', 'decision'],
42
+ ['fact', 'fact'], ['facts', 'fact'], ['hecho', 'fact'], ['hechos', 'fact'],
43
+ ['preference', 'preference'], ['preferences', 'preference'], ['preferencia', 'preference'], ['preferencias', 'preference'],
44
+ ['question', 'question'], ['questions', 'question'], ['pregunta', 'question'], ['preguntas', 'question'],
45
+ ]);
46
+ const isLine = (line) => ORDER_LINE.test(line) || LINK_LINE.test(line);
47
+
48
+ function linkTerms(rest) {
49
+ const [first, ...others] = String(rest).split(/[;·|]/).map((part) => part.trim()).filter(Boolean);
50
+ const words = String(first ?? '').toLowerCase().split(/\s*,\s*|\s+y\s+|\s+and\s+/).map((word) => word.trim()).filter(Boolean);
51
+ const kinds = [...new Set(words.map((word) => KIND_WORDS.get(word)).filter(Boolean))];
52
+ const unknown = words.filter((word) => !KIND_WORDS.has(word));
53
+ let days = null;
54
+ let orders = null;
55
+ for (const part of others) {
56
+ const span = part.match(/(\d{1,3})\s*(?:d|días|dias|day|days)\b/i);
57
+ if (span) days = Number(span[1]);
58
+ const ceiling = part.match(/(?:órdenes|ordenes|orders?)\s*(?:hasta|up to)?\s*#?\s*([0-4])/i);
59
+ if (ceiling) orders = Number(ceiling[1]);
60
+ }
61
+ return { kinds, unknown, days, orders };
62
+ }
63
+
64
+ // The last ```pulse-salas block of a reply, read the way the room reads ```pulse: the reason on the
65
+ // first line, then up to 4 orders and up to 2 links, and nothing after the block. `null` when the
66
+ // reply has no such block; otherwise `{ reason, orders, links, ignored }`, where `ignored` says why
67
+ // each line (or the whole block) was not taken.
68
+ export function parsePlan(text) {
69
+ const lines = String(text ?? '').replace(/\r\n/g, '\n').split('\n');
70
+ // Fences as the page reads them (renderMarkdown): a fence opens with an optional language and
71
+ // only a bare ``` closes it.
72
+ const blocks = [];
73
+ let open = null;
74
+ for (let index = 0; index < lines.length; index += 1) {
75
+ if (open === null) {
76
+ const fence = lines[index].match(/^\s*```\s*([\w+-]*)\s*$/);
77
+ if (fence) open = { start: index, lang: fence[1].toLowerCase() };
78
+ continue;
79
+ }
80
+ if (/^\s*```\s*$/.test(lines[index])) { blocks.push({ ...open, end: index }); open = null; }
81
+ }
82
+ const none = (why) => ({ reason: '', orders: [], links: [], ignored: [{ line: '', why }] });
83
+ if (open?.lang === PLAN_FENCE) return none('the block is not closed');
84
+ const found = blocks.filter((block) => block.lang === PLAN_FENCE).at(-1);
85
+ if (!found) return null;
86
+ if (lines.slice(found.end + 1).join('\n').trim()) return none('text after the block');
87
+ const body = lines.slice(found.start + 1, found.end).map((line) => line.trim()).filter(Boolean);
88
+ const reason = body[0] && !isLine(body[0]) ? clean(body.shift(), 300) : '';
89
+ if (!reason) return none('the first line says why');
90
+ const orders = [];
91
+ const links = [];
92
+ const ignored = [];
93
+ for (const line of body) {
94
+ const link = line.match(LINK_LINE);
95
+ if (link) {
96
+ if (links.length >= MAX_LINKS) { ignored.push({ line: clean(line, 120), why: 'more than 2 connections' }); continue; }
97
+ const terms = linkTerms(link[4]);
98
+ links.push({ from: clean(link[1], 80), to: clean(link[3], 80), both: link[2] === '↔' || link[2] === '<->', ...terms });
99
+ continue;
100
+ }
101
+ const match = line.match(ORDER_LINE);
102
+ if (!match) { ignored.push({ line: clean(line, 120), why: 'not an order line' }); continue; }
103
+ const mode = Number(match[3]);
104
+ if (mode > 4) { ignored.push({ line: clean(line, 120), why: 'no such mode' }); continue; }
105
+ if (orders.length >= MAX_ORDERS) { ignored.push({ line: clean(line, 120), why: 'more than 4 orders' }); continue; }
106
+ orders.push({ room: clean(match[1], 80), agent: match[2].toLowerCase(), mode, text: String(match[4]).replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, ORDER_CHARS) });
107
+ }
108
+ return { reason, orders, links, ignored };
109
+ }
110
+
111
+ /* ---------- what each line means on this Mac ---------- */
112
+
113
+ // A room by its id, or by a name only one room on this Mac has.
114
+ function findRoom(rooms, wanted) {
115
+ const byId = rooms.filter((room) => room.id === wanted);
116
+ if (byId.length) return byId;
117
+ const lower = String(wanted).toLowerCase();
118
+ return rooms.filter((room) => String(room.name).toLowerCase() === lower);
119
+ }
120
+
121
+ // One parsed order, checked: the room, an agent of this crew, a contract in force that lets BISHOP
122
+ // give orders to it and up to which mode. An order that fails a check stays on the card with its
123
+ // reason and no button; #3 and #4 become a proposal for that room.
124
+ export function resolveOrder(order, step, { rooms, contracts, salas, agents }) {
125
+ const crew = new Set((agents ?? []).filter((agent) => agent.detected).map((agent) => agent.id));
126
+ const named = findRoom(rooms, order.room);
127
+ const base = { step, room: named[0]?.id ?? null, name: named[0]?.name ?? order.room, agent: order.agent, mode: order.mode, text: order.text, contract: null, maxMode: null, asked: order.room };
128
+ if (!named.length) return { ...base, status: 'blocked', why: 'MADRE does not know that project on this Mac.' };
129
+ if (named.length > 1) return { ...base, room: null, status: 'blocked', why: 'Two projects on this Mac have that name; use the project id.' };
130
+ if (named[0].id === salas) return { ...base, status: 'blocked', why: 'The Coordinator lives in this project; it gives orders to the others.' };
131
+ if (!crew.has(order.agent)) return { ...base, status: 'blocked', why: 'No such agent on this Mac.' };
132
+ const contract = contracts.find((one) => receivesOrders(one, salas) === named[0].id) ?? null;
133
+ if (!contract) return { ...base, status: 'blocked', why: 'No signed connection lets the Coordinator give orders to that project.' };
134
+ const maxMode = Math.min(contract.orders.maxMode, MAX_ORDER_MODE);
135
+ if (order.mode >= 3) return { ...base, contract: contract.id, maxMode, status: 'proposal', why: '#3 and #4 never travel: unlock it yourself in that project.' };
136
+ if (order.mode > maxMode) return { ...base, contract: contract.id, maxMode, status: 'blocked', why: 'That connection allows orders up to #{n}.', whyArgs: { n: maxMode } };
137
+ return { ...base, contract: contract.id, maxMode, status: 'ready', why: null };
138
+ }
139
+ export const resolveOrders = ({ orders, ...where }) => orders.map((order, index) => resolveOrder(order, index + 1, where));
140
+
141
+ // One proposed link, checked the way the sheet of Salas and `normalizeContract` would: two rooms
142
+ // this Mac knows, the room of Salas never handing over, kinds that may cross, orders only with
143
+ // Salas at one end. A pair already connected, either way round, says so. Signing it is the human's press.
144
+ export function resolveLinks({ links, rooms, contracts, salas }) {
145
+ return links.map((link, index) => {
146
+ const from = findRoom(rooms, link.from);
147
+ const to = findRoom(rooms, link.to);
148
+ const base = { index, from: from[0]?.id ?? null, fromName: from[0]?.name ?? link.from, to: to[0]?.id ?? null, toName: to[0]?.name ?? link.to, direction: link.both ? 'both' : 'a→b', kinds: link.kinds, days: link.days, orders: null, receives: null };
149
+ if (from.length !== 1 || to.length !== 1) return { ...base, status: 'blocked', why: from.length > 1 || to.length > 1 ? 'Two projects on this Mac have that name; use the project id.' : 'MADRE does not know that project on this Mac.' };
150
+ if (base.from === base.to) return { ...base, status: 'blocked', why: 'A project cannot be connected to itself.' };
151
+ if (base.from === salas || (link.both && base.to === salas)) return { ...base, status: 'blocked', why: 'The Coordinator\'s project never hands its notes over.' };
152
+ if (!link.kinds.length || link.unknown.length) return { ...base, status: 'blocked', why: 'Name what crosses: decision, fact, preference or question.' };
153
+ if (!link.kinds.every((kind) => LINK_KINDS.includes(kind))) return { ...base, status: 'blocked', why: 'Name what crosses: decision, fact, preference or question.' };
154
+ let orders = null;
155
+ if (link.orders !== null) {
156
+ if (base.from !== salas && base.to !== salas) return { ...base, status: 'blocked', why: 'Orders go only on a connection with the Coordinator\'s project.' };
157
+ orders = { allowed: true, maxMode: Math.min(link.orders, MAX_ORDER_MODE), perHour: 6 };
158
+ }
159
+ const receives = orders ? (base.from === salas ? base.to : base.from) : null;
160
+ // Two projects are connected once (2026-10-11): any connection in force between them, whichever
161
+ // way it goes, is the one there is; signing another is refused (src/server.mjs).
162
+ const same = contracts.find((one) => [one.rooms.a, one.rooms.b].sort().join() === [base.from, base.to].sort().join());
163
+ if (same) return { ...base, orders, receives, status: 'exists', why: 'That connection is already in force.', contract: same.id };
164
+ return { ...base, orders, receives, status: 'ready', why: null };
165
+ });
166
+ }
167
+
168
+ /* ---------- the plans, kept beside the room of Salas ---------- */
169
+
170
+ // What the buttons act on is read from here, never from the page: the page names a plan and a step.
171
+ const plansFile = (stateRoot) => join(stateRoot, 'rooms', salasRoomId(stateRoot), 'bishop-plans.json');
172
+ const writes = new Map();
173
+ async function readPlans(stateRoot) {
174
+ try { const data = JSON.parse(await readFile(plansFile(stateRoot), 'utf8')); return data && typeof data.plans === 'object' ? data : { plans: {} }; } catch { return { plans: {} }; }
175
+ }
176
+ // One change at a time per file, written whole and renamed into place.
177
+ export function changePlans(stateRoot, change) {
178
+ const file = plansFile(stateRoot);
179
+ const run = (writes.get(file) ?? Promise.resolve()).then(async () => {
180
+ const data = await readPlans(stateRoot);
181
+ const result = await change(data);
182
+ const kept = Object.values(data.plans).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))).slice(0, KEEP_PLANS);
183
+ data.plans = Object.fromEntries(kept.map((plan) => [plan.id, plan]));
184
+ await mkdir(join(file, '..'), { recursive: true });
185
+ const temp = `${file}.${process.pid}.${randomBytes(3).toString('hex')}.tmp`;
186
+ await writeFile(temp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
187
+ await rename(temp, file);
188
+ return result;
189
+ });
190
+ writes.set(file, run.catch(() => null));
191
+ return run;
192
+ }
193
+ export const planById = async (stateRoot, id) => (await readPlans(stateRoot)).plans[id] ?? null;
194
+ export const newPlanId = () => `p-${randomBytes(3).toString('hex')}`;
195
+
196
+ // A plan closes once, when no order is still waiting for a press or an answer and at least one
197
+ // was answered, failed or skipped. The close is a line; it never opens another plan.
198
+ const OPEN_STATES = new Set(['ready', 'sending', 'sent']);
199
+ export function closing(plan) {
200
+ if (plan.closed || plan.orders.some((order) => OPEN_STATES.has(order.status))) return null;
201
+ if (!plan.orders.some((order) => ['done', 'failed', 'skipped'].includes(order.status))) return null;
202
+ plan.closed = true;
203
+ const count = (...statuses) => plan.orders.filter((order) => statuses.includes(order.status)).length;
204
+ return { plan: plan.id, done: count('done'), failed: count('failed'), skipped: count('skipped'), blocked: count('blocked', 'proposal', 'delivered') };
205
+ }
206
+ export const publicOrder = ({ step, room, name, agent, mode, text, contract, maxMode, status, why, whyArgs }) => ({ step, room, name, agent, mode, text, contract, maxMode, status, why, ...(whyArgs ? { whyArgs } : {}) });
207
+ export const publicLink = ({ index, from, fromName, to, toName, direction, kinds, days, orders, receives, status, why, contract }) => ({ index, from, fromName, to, toName, direction, kinds, days, orders, receives, status, why, ...(contract ? { contract } : {}) });