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/commands/gm.js +1 -1
- package/commands/task.js +4 -2
- package/commands/xp.js +68 -15
- package/commands/zen.js +10 -3
- package/lib/career-xp-lock.js +61 -0
- package/lib/career-xp-root.js +61 -0
- package/lib/gm-bet.js +405 -17
- package/lib/gm-card.js +431 -0
- package/lib/gm-phone-page.js +481 -1004
- package/lib/gm-phone.js +33 -38
- package/lib/task-db.js +8 -4
- package/lib/yes-lock.js +22 -3
- package/lib/zen-apply.js +1 -0
- package/lib/zen.js +15 -0
- package/package.json +1 -1
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
|
+
};
|