atris 3.74.5 → 3.74.6

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/lib/gm-card.js ADDED
@@ -0,0 +1,431 @@
1
+ 'use strict';
2
+
3
+ // The one card contract between the desk on the Mac and the phone page.
4
+ //
5
+ // The desk serves exactly one card at a time:
6
+ // { id, kind, title, body, options: [{ id, label, action, binds }], input, job, table }
7
+ // with a few more fields the page reads the same way for every kind:
8
+ // option.style primary | choice | option | quiet: how big the button is
9
+ // option.confirm the label a first tap turns the button into; the second tap sends
10
+ // option.type { placeholder, max }: the button turns into a typing box
11
+ // option.detail one plain line under the label
12
+ // option.chosen this one is picked already (a swap's scope, the model in use)
13
+ // option.cell { row, column }: drawn inside that table cell, not in the list
14
+ // option.group a few words that head a line of quiet options sharing them
15
+ // input { label, placeholder, action, max, kind }: the quiet line that opens into a box
16
+ // thread the turns so far, on a reply
17
+ // line one plain line under the body (who has it, what came of a word)
18
+ // count, label a deck card's place in the deck and its status
19
+ // watch the page looks again for the next idea while this shows
20
+ //
21
+ // A reply is a new card with a new id; nothing is ever patched in place.
22
+ // A card's id carries a hash of its words and options (stamp), so options
23
+ // that change, or mean something else, always come under a new id.
24
+ // Every action names the card it acts on and one option on it, or typed
25
+ // words for its input; the desk refuses anything else (lib/gm-bet.js act).
26
+ //
27
+ // The invariants live here, in cardProblems, and the desk checks every card
28
+ // before it serves it (enforce). Each option the desk builds says what it
29
+ // answers (answers: the card's asks) or that it only moves around (nav), so
30
+ // a card can never show an option that answers some other words.
31
+
32
+ const crypto = require('node:crypto');
33
+ const hash8 = x => crypto.createHash('sha256').update(JSON.stringify(x)).digest('hex').slice(0, 8);
34
+
35
+ const KINDS = new Set(['idea', 'question', 'report', 'choice', 'yes', 'done', 'wait', 'reply', 'started', 'team', 'member', 'deck', 'swap']);
36
+ const STYLES = new Set(['primary', 'choice', 'option', 'quiet']);
37
+ const SAY = { label: 'or say something', placeholder: 'say something', action: 'say', max: 280 };
38
+
39
+ // Words a person reads: no code spans, flags, paths, file names, or commands.
40
+ // A tool's name inside a sentence can be the product ("a page for atris gm
41
+ // phone"); a command is one the words tell someone to run, or start with.
42
+ const CODE = [
43
+ /`/,
44
+ /(^|\s)[\w.~-]*\/[\w./-]+/,
45
+ /\b[\w-]+\.(js|ts|tsx|sh|md|json|py|yml|yaml|txt|html|css|jsonl)\b/i,
46
+ /(^|\s)--?[a-z]/i,
47
+ /(^|\b(run|ran|running|type|typed)\s+)(npm|npx|git|node|gh|atris)\s+(install|run|publish|test|view|push|commit|merge|tag|log|pr|task|mission|gm|slop|release|init)\b/i,
48
+ ];
49
+ const isPlain = text => !CODE.some(re => re.test(String(text || '')));
50
+
51
+ // The same words with the code taken out, for a line that slipped through.
52
+ function plainWords(text) {
53
+ const t = String(text || '');
54
+ if (isPlain(t)) return t;
55
+ return t.replace(/`[^`]*`/g, ' ')
56
+ .split(/\s+/)
57
+ .filter(w => w && !/^--?[a-z]/i.test(w))
58
+ .map(w => (/\/|\.(js|ts|tsx|sh|md|json|py|yml|yaml|txt|html|css|jsonl)\b/i.test(w) ? 'a file' : w))
59
+ .join(' ')
60
+ .replace(/\b(npm|npx|git|node|gh|atris)\s+[a-z-]+\b/gi, 'a command')
61
+ .replace(/\s+([,.;:])/g, '$1')
62
+ .replace(/\s+/g, ' ')
63
+ .trim();
64
+ }
65
+
66
+ // ---- building options ---------------------------------------------------------------
67
+
68
+ // an option that answers the card's own words
69
+ const answer = (asks, o) => ({ style: 'quiet', ...o, answers: asks });
70
+ // an option that only moves around: back, the deck's next, more models
71
+ const nav = o => ({ style: 'quiet', ...o, nav: true });
72
+
73
+ const sayInput = (over = {}) => ({ ...SAY, ...over });
74
+
75
+ function idea({ id, title, body, line = null, unstarted = null }) {
76
+ return {
77
+ id, kind: 'idea', asks: id, title, body,
78
+ line: line || unstarted || null,
79
+ options: [
80
+ answer(id, { id: 'do', label: 'do it', action: 'do', style: 'primary', confirm: 'tap again to start' }),
81
+ answer(id, { id: 'sharper', label: 'sharper idea', action: 'sharper' }),
82
+ answer(id, { id: 'skip', label: 'not now', action: 'skip' }),
83
+ ],
84
+ input: sayInput(),
85
+ };
86
+ }
87
+
88
+ // a started idea: who has it; the next card comes on the next tap
89
+ function started({ id, title, body, line }) {
90
+ return { id, kind: 'started', asks: id, title, body, line, options: [nav({ id: 'next', label: 'next', action: 'next' })], input: null };
91
+ }
92
+
93
+ // choices: [{ id, label, other }]; an other choice turns into a box
94
+ function question({ id, title, body = null, choices }) {
95
+ const other = choices.some(c => c.other);
96
+ return {
97
+ id, kind: 'question', asks: id, title, body,
98
+ options: choices.map(c => answer(id, c.other
99
+ ? { id: c.id, label: c.label, action: 'say', style: 'choice', type: { placeholder: c.label, max: 60 } }
100
+ : { id: c.id, label: c.label, action: 'answer', style: 'choice', binds: { choice: c.id } })),
101
+ // its own something else box is the one place to type
102
+ input: other ? null : sayInput(),
103
+ };
104
+ }
105
+
106
+ function report({ id, title, body }) {
107
+ return {
108
+ id, kind: 'report', asks: id, title, body,
109
+ options: [answer(id, { id: 'later', label: 'not now', action: 'later' })],
110
+ input: { label: 'type this week\'s number', placeholder: 'this week\'s number', action: 'report', max: 40 },
111
+ };
112
+ }
113
+
114
+ // items: [{ id, title, why }]
115
+ function choice({ id, items }) {
116
+ return {
117
+ id, kind: 'choice', asks: id, title: 'which one next?', body: null,
118
+ options: items.map(i => answer(id, { id: i.id, label: i.title, detail: i.why, action: 'pick', style: 'option', binds: { bet: i.id } })),
119
+ input: sayInput(),
120
+ };
121
+ }
122
+
123
+ // The yes and the no bind the one request the card shows: its job and its
124
+ // command's hash (a plain ask has no hash, only its place in the asks).
125
+ function yes({ id, title, body, request, job = null, line = null }) {
126
+ const binds = { job: request.job, key: request.key, hash: request.hash || null };
127
+ return {
128
+ id, kind: 'yes', asks: id, title, body, job, line, request,
129
+ options: [
130
+ answer(id, { id: 'yes', label: 'yes', action: 'yes', style: 'primary', confirm: 'tap again to say yes', binds }),
131
+ answer(id, { id: 'no', label: 'no', action: 'no', binds }),
132
+ answer(id, { id: 'later', label: 'not now', action: 'later', binds }),
133
+ ],
134
+ input: sayInput(),
135
+ };
136
+ }
137
+
138
+ function done({ id, title, body, job = null }) {
139
+ return { id, kind: 'done', asks: id, title, body, job, options: [answer(id, { id: 'nice', label: 'nice', action: 'nice', style: 'primary' })], input: sayInput() };
140
+ }
141
+
142
+ function wait({ id }) {
143
+ return { id, kind: 'wait', asks: id, title: 'writing your next idea.', body: null, options: [], input: sayInput(), watch: true };
144
+ }
145
+
146
+ // A reply to words said on a card. When it asks its own question (it ends
147
+ // in a question mark) only its answers show, with the box and a quiet way
148
+ // back to the first question. When it only explains, the first card's own
149
+ // options show once, and any answer it adds to them after. A member that
150
+ // is not running takes a second tap to start, or the words are kept as a note.
151
+ // base: the card the words were said on.
152
+ function reply({ id, base, text, thread = [], suggestions = [], confirm = null }) {
153
+ const own = Boolean(confirm) || /\?\s*$/.test(String(text || ''));
154
+ const card = { id, kind: 'reply', title: text, body: null, thread, job: base.job || null, base: base.id, table: null };
155
+ if (confirm) {
156
+ return {
157
+ ...card, asks: id,
158
+ options: [
159
+ answer(id, { id: 'start', label: 'start it', action: 'start', style: 'choice', binds: { text: confirm.text } }),
160
+ answer(id, { id: 'note', label: 'keep it as a note instead', action: 'note', style: 'choice', binds: { text: confirm.text } }),
161
+ nav({ id: 'back', label: 'back', action: 'back' }),
162
+ ],
163
+ input: base.input || null,
164
+ };
165
+ }
166
+ if (own) {
167
+ return {
168
+ ...card, asks: id,
169
+ options: [
170
+ ...suggestions.map((s, i) => answer(id, { id: `r${i + 1}`, label: s, action: 'say', style: 'choice', binds: { text: s } })),
171
+ nav({ id: 'back', label: base.kind === 'deck' ? 'back to the job' : base.kind === 'member' ? `back to ${base.title}` : 'back to the first question', action: 'back' }),
172
+ ],
173
+ input: base.input || sayInput(),
174
+ };
175
+ }
176
+ // it explains the card it was said on: that card's options, once, any
177
+ // answer the reply adds to them, and its box; its way back returns to that card
178
+ const backTo = base.kind === 'deck' ? 'back to the job' : base.kind === 'member' ? `back to ${base.title}` : 'back';
179
+ const kept = base.options.filter(o => !o.nav || o.action === 'back').map(o => (o.action === 'back' ? { ...o, label: backTo } : o));
180
+ const have = new Set(kept.map(o => String(o.label).trim().toLowerCase()));
181
+ const added = suggestions.filter(s => !have.has(String(s).trim().toLowerCase()))
182
+ .map((s, i) => answer(base.asks, { id: `r${i + 1}`, label: s, action: 'say', style: 'choice', binds: { text: s } }));
183
+ // the answers it adds sit with the card's own answers, before its quiet links
184
+ const firstQuiet = kept.findIndex(o => !o.style || o.style === 'quiet');
185
+ const options = firstQuiet < 0 ? [...kept, ...added] : [...kept.slice(0, firstQuiet), ...added, ...kept.slice(firstQuiet)];
186
+ return {
187
+ ...card, asks: base.asks,
188
+ options: options.length ? options : [nav({ id: 'back', label: 'back', action: 'back' })],
189
+ input: base.input || (base.options.some(o => o.type) ? null : sayInput()),
190
+ request: base.request,
191
+ };
192
+ }
193
+
194
+ // ---- the team, a member, the job deck, and the swap card -----------------------------
195
+
196
+ const TEAM_COLUMNS = ['member', 'role', 'ovr', 'engine', 'status'];
197
+
198
+ // rows: the team table's rows. A tap on a member opens their talk card; a
199
+ // tap on their engine opens the swap card (a person has no engine to swap).
200
+ function team({ id, rows, line = null, deck = false }) {
201
+ const options = [];
202
+ rows.forEach((r, i) => {
203
+ options.push(nav({ id: `talk:${r.member}`, label: r.member, action: 'member', binds: { member: r.member }, cell: { row: i, column: 'member' } }));
204
+ if (r.engine !== 'person') options.push(nav({ id: `swap:${r.member}`, label: r.engine, action: 'swap', binds: { member: r.member }, cell: { row: i, column: 'engine' } }));
205
+ });
206
+ if (deck) options.push(nav({ id: 'deck', label: 'the jobs, one at a time', action: 'deck' }));
207
+ options.push(nav({ id: 'back', label: 'close', action: 'back' }));
208
+ return {
209
+ id, kind: 'team', asks: id, title: 'the team', body: rows.length ? 'tap a name to talk to them, or what they run on to change it.' : 'nobody is on the team yet.', line,
210
+ table: rows.length ? { columns: TEAM_COLUMNS, rows: rows.map(r => [r.member, r.role, r.ovr, r.engine, r.sentence]), status: rows.map(r => r.status) } : null,
211
+ options, input: null,
212
+ };
213
+ }
214
+
215
+ function member({ id, name, sentence: said, job = null, line = null }) {
216
+ return {
217
+ id, kind: 'member', asks: id, title: name, body: said, job, line,
218
+ options: [nav({ id: 'back', label: 'back to the team', action: 'back' })],
219
+ input: { ...SAY, label: `tell ${name} something`, placeholder: `tell ${name} something`, action: 'talk' },
220
+ member: name,
221
+ };
222
+ }
223
+
224
+ // what a job in the deck is doing, when there is no yes on it to answer
225
+ function deckLine(card) {
226
+ const when = card.time ? `, ${card.time}` : '';
227
+ if (card.label === 'working') return `${card.member} is working on it${when}. nothing needs you here.`;
228
+ if (card.label === 'you answered') return `you answered this, so it is back with ${card.member}.`;
229
+ return `${card.member} has not started it yet${when}.`;
230
+ }
231
+
232
+ // one running job: its place in the deck, the yes it waits on, its table
233
+ // row, and a box to talk to its member
234
+ function deck({ id, at, total, card, columns, job = null }) {
235
+ const options = [];
236
+ if (card.yes) {
237
+ const binds = { job: card.mission_id, key: card.yes.key, hash: card.yes.hash || null };
238
+ options.push(answer(id, { id: 'yes', label: 'yes', action: 'yes', style: 'primary', confirm: 'tap again to say yes', binds }));
239
+ options.push(answer(id, { id: 'no', label: 'no', action: 'no', binds }));
240
+ options.push(answer(id, { id: 'later', label: 'not now', action: 'later', binds }));
241
+ }
242
+ if (total > 1) options.push(nav({ id: 'prev', label: 'previous', action: 'flip', binds: { step: -1 } }));
243
+ options.push(nav({ id: 'back', label: 'the whole team', action: 'back' }));
244
+ if (total > 1) options.push(nav({ id: 'next', label: 'next', action: 'flip', binds: { step: 1 } }));
245
+ return {
246
+ id, kind: 'deck', asks: id,
247
+ title: card.yes ? card.yes.what : card.rows[0].work,
248
+ body: card.yes ? card.yes.why : deckLine(card),
249
+ line: card.said || null,
250
+ count: `${at + 1} of ${total}`,
251
+ label: card.label,
252
+ time: card.time || null,
253
+ job,
254
+ table: { columns, rows: card.rows.map(r => columns.map(k => r[k] || '')) },
255
+ options,
256
+ input: { ...SAY, label: `talk to ${card.member}`, placeholder: `talk to ${card.member}`, action: 'talk' },
257
+ member: card.member,
258
+ request: card.yes ? { job: card.mission_id, key: card.yes.key, hash: card.yes.hash || null } : null,
259
+ deck_job: card.job_id,
260
+ };
261
+ }
262
+
263
+ // The swap card renders the swap list as it comes: Atris models, then the
264
+ // owner's picks, then "more models", which turns this card's options into
265
+ // the full list. A list with no picks opens short too: the model in use and
266
+ // the next two, then more models, when that hides at least two. Without the
267
+ // list, every model by tool.
268
+ const SHORT_LIST = 3;
269
+ function swapChoices(choices, all = false) {
270
+ const flat = Array.isArray(choices.options)
271
+ ? choices.options
272
+ : (choices.groups || []).flatMap(g => g.models.map(m => ({ ...m, tool: g.tool })));
273
+ let more = flat.find(o => o.more) || null;
274
+ let list = flat.filter(o => !o.more);
275
+ if (!more && list.length > SHORT_LIST + 1) {
276
+ more = { more: true, label: 'more models', options: list };
277
+ list = [...list.filter(o => o.current), ...list.filter(o => !o.current)].slice(0, SHORT_LIST);
278
+ }
279
+ if (all && more) return { list: more.options || [], more: null };
280
+ return { list, more };
281
+ }
282
+
283
+ // The models first, each its own option id from the model it picks, so no
284
+ // id ever points at another model; then more models, then who a swap is for.
285
+ function swap({ id, name, choices, scope = 'member', all = false, line = null }) {
286
+ const { list, more } = swapChoices(choices, all);
287
+ const options = list.map((m) => {
288
+ const detail = m.current ? 'now' : [m.tool, m.record].filter(Boolean).join(', ') || null;
289
+ return answer(id, { id: `m-${hash8([m.engine, m.model, m.label])}`, label: m.label, detail, action: m.current ? 'none' : 'swap_to', style: 'choice', chosen: Boolean(m.current), binds: { engine: m.engine, model: m.model, scope } });
290
+ });
291
+ if (more) options.push(nav({ id: 'more', label: more.label || 'more models', action: 'more' }));
292
+ if ((choices.scopes || []).length > 1) {
293
+ const words = sc => (sc.id === 'member' ? `only ${name}` : `everyone doing ${name}'s kind of work`);
294
+ for (const sc of choices.scopes) options.push(answer(id, { id: `scope:${sc.id}`, label: words(sc), action: 'scope', binds: { scope: sc.id }, chosen: sc.id === scope, group: 'who it is for' }));
295
+ }
296
+ options.push(nav({ id: 'back', label: 'back to the team', action: 'back' }));
297
+ const how = (choices.scopes || []).length > 1 ? `tap a model to switch ${name} to it. it changes only ${name}, unless you pick everyone below first.` : `tap a model to switch ${name} to it.`;
298
+ return { id, kind: 'swap', asks: id, title: name, body: [choices.line, how].filter(Boolean).join(' '), line, options, input: null, member: name, scope, all };
299
+ }
300
+
301
+ // ---- the invariants ---------------------------------------------------------------
302
+
303
+ // Everything wrong with one card, in plain words; [] when it is whole.
304
+ function cardProblems(card) {
305
+ const out = [];
306
+ if (!card || typeof card !== 'object') return ['there is no card'];
307
+ if (!card.id || typeof card.id !== 'string') out.push('it has no id');
308
+ if (!KINDS.has(card.kind)) out.push(`kind ${card.kind} is not a card kind`);
309
+ if (!String(card.title || '').trim()) out.push('it has no title');
310
+ const options = Array.isArray(card.options) ? card.options : [];
311
+ if (!Array.isArray(card.options)) out.push('it has no option list');
312
+ if (!options.length && !card.input) out.push('it has no way forward: no option and no box');
313
+ const ids = new Set();
314
+ for (const o of options) {
315
+ if (!o.id || ids.has(o.id)) out.push(`option ${o.id || '(no id)'} has no id of its own`);
316
+ ids.add(o.id);
317
+ if (!String(o.label || '').trim()) out.push(`option ${o.id} has no label`);
318
+ if (!o.action) out.push(`option ${o.id} does nothing`);
319
+ if (o.style && !STYLES.has(o.style)) out.push(`option ${o.id} has style ${o.style}`);
320
+ // the options shown answer the words shown
321
+ if (!o.nav && o.answers !== card.asks) out.push(`option ${o.id} (${o.label}) answers other words than the card shows`);
322
+ }
323
+ // no raw commands, flags, or paths anywhere a person reads
324
+ const read = [['title', card.title], ['body', card.body], ['line', card.line], ...options.flatMap(o => [[`option ${o.id}`, o.label], [`option ${o.id} detail`, o.detail], [`option ${o.id} group`, o.group]])];
325
+ if (card.input) read.push(['box', card.input.label], ['box', card.input.placeholder]);
326
+ if (card.table) for (const row of card.table.rows) for (const cell of row) read.push(['table', cell]);
327
+ for (const [where, text] of read) if (text && !isPlain(text)) out.push(`the ${where} has a command, flag, or path in it: ${String(text).slice(0, 80)}`);
328
+ // a yes card says yes, no, and not now, and each binds the request it shows
329
+ const asksYes = card.kind === 'yes' || (card.kind === 'deck' && card.request) || (card.kind === 'reply' && card.request && options.some(o => o.action === 'yes'));
330
+ if (asksYes) {
331
+ for (const want of ['yes', 'no', 'later']) if (!options.some(o => o.id === want)) out.push(`a yes card has no ${want === 'later' ? 'not now' : want}`);
332
+ const r = card.request || {};
333
+ for (const o of options.filter(x => x.action === 'yes' || x.action === 'no')) {
334
+ const b = o.binds || {};
335
+ if (!r.job || b.job !== r.job || b.key !== r.key || (b.hash || null) !== (r.hash || null)) out.push(`option ${o.id} does not bind the request on the card`);
336
+ }
337
+ }
338
+ return out;
339
+ }
340
+
341
+ // Over a run of cards as they were served: never two yes cards in a row.
342
+ function sequenceProblems(cards) {
343
+ const out = [];
344
+ cards.forEach((c, i) => { if (i && c && cards[i - 1] && c.kind === 'yes' && cards[i - 1].kind === 'yes') out.push(`cards ${i} and ${i + 1} are both yes cards`); });
345
+ return out;
346
+ }
347
+
348
+ // Words the owner reads say what a thing is, not where a number comes from:
349
+ // "300 more npm installs" reads "300 more installs".
350
+ const ownerText = t => (typeof t === 'string' ? t.replace(/\bnpm (installs?|downloads?)\b/gi, '$1') : t);
351
+ function ownerWords(card) {
352
+ const out = { ...card, title: ownerText(card.title), body: ownerText(card.body), line: ownerText(card.line) };
353
+ out.options = (card.options || []).map(o => ({ ...o, label: ownerText(o.label), ...(o.detail ? { detail: ownerText(o.detail) } : {}) }));
354
+ if (card.table) out.table = { ...card.table, rows: card.table.rows.map(row => row.map(ownerText)) };
355
+ return out;
356
+ }
357
+
358
+ // The card as it is served, in the owner's words. A card with a problem is
359
+ // made safe and the problem is said: code is taken out of the words, an
360
+ // option that answers other words is dropped, and a card with no way
361
+ // forward gets a next.
362
+ function enforce(raw, report = () => {}) {
363
+ const card = ownerWords(raw);
364
+ const problems = cardProblems(card);
365
+ if (!problems.length) return card;
366
+ report(problems);
367
+ const fixed = { ...card, title: plainWords(card.title) || 'next', body: card.body && plainWords(card.body), line: card.line && plainWords(card.line) };
368
+ fixed.options = (card.options || []).filter(o => o.nav || o.answers === card.asks).map(o => ({ ...o, label: plainWords(o.label), ...(o.detail ? { detail: plainWords(o.detail) } : {}) }));
369
+ if (fixed.table) fixed.table = { ...fixed.table, rows: fixed.table.rows.map(row => row.map(plainWords)) };
370
+ if (!fixed.options.length && !fixed.input) fixed.options = [nav({ id: 'next', label: 'next', action: 'next' })];
371
+ return fixed;
372
+ }
373
+
374
+ // What goes over the wire: the contract fields, without what the desk keeps
375
+ // to check and act on (asks, answers, nav, request, binds of its own).
376
+ const OPTION_WIRE = ['id', 'label', 'action', 'binds', 'style', 'confirm', 'type', 'detail', 'chosen', 'cell', 'group'];
377
+ function wire(card) {
378
+ if (!card) return null;
379
+ const out = {
380
+ id: card.id,
381
+ kind: card.kind,
382
+ title: card.title,
383
+ body: card.body || null,
384
+ options: (card.options || []).map((o) => {
385
+ const w = {};
386
+ for (const k of OPTION_WIRE) if (o[k] !== undefined && o[k] !== null && o[k] !== false) w[k] = o[k];
387
+ return w;
388
+ }),
389
+ input: card.input || null,
390
+ job: card.job || null,
391
+ table: card.table || null,
392
+ };
393
+ for (const k of ['thread', 'line', 'count', 'label', 'time', 'watch']) if (card[k] !== undefined && card[k] !== null && !(Array.isArray(card[k]) && !card[k].length)) out[k] = card[k];
394
+ return out;
395
+ }
396
+
397
+ // The id a card is served under: its own id and a hash of its words and
398
+ // every option with what it binds, so new options always mean a new id.
399
+ function stamp(card) {
400
+ if (!card) return card;
401
+ const offer = {
402
+ title: card.title,
403
+ options: (card.options || []).map(o => [o.id, o.label, o.action, o.binds || null, o.confirm || null, o.type || null, Boolean(o.chosen)]),
404
+ input: card.input ? [card.input.action, card.input.label] : null,
405
+ request: card.request || null,
406
+ };
407
+ return { ...card, id: `${card.id}#${hash8(offer)}` };
408
+ }
409
+
410
+ module.exports = {
411
+ stamp,
412
+ KINDS,
413
+ TEAM_COLUMNS,
414
+ cardProblems,
415
+ sequenceProblems,
416
+ enforce,
417
+ wire,
418
+ idea,
419
+ started,
420
+ question,
421
+ report,
422
+ choice,
423
+ yes,
424
+ done,
425
+ wait,
426
+ reply,
427
+ team,
428
+ member,
429
+ deck,
430
+ swap,
431
+ };