@bongos/core 1.24.7 → 1.24.8
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/.bongos-core.json +97 -37
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +12 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/api/openapi.json +260 -3
- package/docs/api-reference.md +6 -2
- package/docs/copy-inventory.md +27 -4
- package/docs/copy-registry.json +244 -6
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +40 -4
- package/docs/page-readings.json +61 -1
- package/modules/government/decision-board.js +5 -0
- package/modules/government/decision-rules.js +3 -0
- package/modules/government/routes/government.js +2 -0
- package/modules/hall-ui/public/market-watch-rules-lib.js +207 -0
- package/modules/hall-ui/public/market-watch-rules.css +68 -0
- package/modules/hall-ui/public/market-watch-rules.html +54 -0
- package/modules/hall-ui/public/market-watch-rules.js +129 -0
- package/modules/hall-ui/public/market-watch-rules.states.json +17 -0
- package/modules/market-watch/matter-acts.js +59 -0
- package/modules/market-watch/module.json +5 -4
- package/modules/market-watch/proposals.js +214 -0
- package/modules/market-watch/research.js +49 -8
- package/modules/market-watch/routes/rules.js +202 -0
- package/modules/market-watch/rules-logic.js +264 -0
- package/modules/market-watch/rules-view.js +165 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/scripts/bongos/government-walk.js +18 -2
- package/scripts/bongos/run-unit-tests.js +3 -0
- package/src/bongos/module-scope-map.js +2 -0
- package/src/bongos/serve-internal.js +2 -1
- package/src/module-api.js +1 -1
- package/tests/government_act_alone_buildings.mjs +3 -0
- package/tests/government_decision_rules.mjs +4 -1
- package/tests/governor_matter_acts.mjs +5 -1
- package/tests/hall_page_gate_map.mjs +3 -0
- package/tests/market_watch_module.mjs +1 -0
- package/tests/market_watch_rules.mjs +496 -0
- package/tests/market_watch_rules_db.mjs +133 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// market-watch-rules-lib.js — the Market Watch rules page's markup, pure (task 1004941).
|
|
2
|
+
//
|
|
3
|
+
// Built to docs/design/mocks/market-watch/rules.html, option C, the pipeline map: a
|
|
4
|
+
// version slider (v1 to the version in use, plus the proposed next one), the five steps
|
|
5
|
+
// as a row of nodes (searches → sources → names → scoring → brief), the tapped step's
|
|
6
|
+
// rules in plain words with its open proposals, and the cost of one run by step.
|
|
7
|
+
//
|
|
8
|
+
// Every string here comes from the server's GET /market-watch/rules and much of it is
|
|
9
|
+
// member-typed (reasons, names, searches), so every one goes through `esc`; nothing is
|
|
10
|
+
// inserted as HTML. Node-require-safe: no document, no fetch, so a test renders the real
|
|
11
|
+
// markup from a real view.
|
|
12
|
+
(function (root) {
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const ASK = () => (root && root.OTBAsk) || (typeof require === 'function' ? require('./hall-ask.js') : null);
|
|
16
|
+
const money = (n) => `$${(Number(n) || 0).toFixed(2)}`;
|
|
17
|
+
|
|
18
|
+
// The slider's stops: every version that was in use, then the proposed next one.
|
|
19
|
+
function stops(view) {
|
|
20
|
+
const out = (view.history || []).map((h) => ({
|
|
21
|
+
version: h.version, kind: h.status === 'active' ? 'now' : 'past', summary: h.summary, changed: h.changed || [], rules: h.rules, entry: h,
|
|
22
|
+
}));
|
|
23
|
+
if (view.next) {
|
|
24
|
+
out.push({
|
|
25
|
+
version: view.next.version,
|
|
26
|
+
kind: 'next',
|
|
27
|
+
summary: view.next.applied === 1 ? 'if you approve the proposal' : `if you approve all ${view.next.applied} proposals`,
|
|
28
|
+
changed: view.next.changed || [],
|
|
29
|
+
rules: view.next.rules,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function stopLabel(stop) {
|
|
36
|
+
if (!stop) return '';
|
|
37
|
+
return `v${stop.version}${stop.kind === 'now' ? ' · now' : stop.kind === 'next' ? ' · proposed' : ''}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sliderHtml(view, at, esc) {
|
|
41
|
+
const s = stops(view);
|
|
42
|
+
if (!s.length) return '';
|
|
43
|
+
const i = Math.max(0, Math.min(s.length - 1, at));
|
|
44
|
+
const cur = s[i];
|
|
45
|
+
const pill = cur.kind === 'now' ? 'in use' : cur.summary || '';
|
|
46
|
+
const ticks = s.map((x) => `<span>v${esc(x.version)}${x.kind === 'next' ? '?' : ''}</span>`).join('');
|
|
47
|
+
return `<div class="mwr-time">`
|
|
48
|
+
+ `<span class="mwr-mono" id="mwr-vl">${esc(stopLabel(cur))}</span>`
|
|
49
|
+
+ `<input type="range" id="mwr-v" min="0" max="${s.length - 1}" value="${i}" aria-label="rules version"${s.length < 2 ? ' disabled' : ''}>`
|
|
50
|
+
+ `<span class="mwr-pill${cur.kind === 'now' ? ' mwr-pill--hot' : ''}" id="mwr-vs">${esc(pill)}</span>`
|
|
51
|
+
+ `</div><div class="mwr-ticks" aria-hidden="true">${ticks}</div>`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The five nodes. `changed` marks the steps the slider's version changed.
|
|
55
|
+
function pipelineHtml(view, { open, changed = [] }, esc) {
|
|
56
|
+
return (view.steps || []).map((s, i) => {
|
|
57
|
+
const cls = `mwr-node${changed.includes(s.key) ? ' mwr-node--changed' : ''}`;
|
|
58
|
+
const badge = s.open ? `<span class="mwr-badge" aria-label="${esc(s.open)} open proposals">${esc(s.open)}</span>` : '';
|
|
59
|
+
const node = `<button type="button" class="${cls}" data-step="${esc(s.key)}" aria-pressed="${s.key === open}">`
|
|
60
|
+
+ `<span class="mwr-node__k">${esc(s.label)}</span><span class="mwr-node__v">${esc(s.value)}</span>`
|
|
61
|
+
+ `<span class="mwr-node__s">${esc(s.sub)}</span>${badge}</button>`;
|
|
62
|
+
return node + (i < view.steps.length - 1 ? '<span class="mwr-arrow" aria-hidden="true">→</span>' : '');
|
|
63
|
+
}).join('');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A step's rules, as the chosen version has them.
|
|
67
|
+
function rulesBodyHtml(step, rules, view, esc) {
|
|
68
|
+
const r = rules || {};
|
|
69
|
+
const rows = (list) => (list && list.length
|
|
70
|
+
? `<ul class="mwr-rows">${list.map((x) => `<li class="mwr-row"><span class="mwr-grow">${esc(x)}</span></li>`).join('')}</ul>`
|
|
71
|
+
: '<p class="mwr-empty">Nothing here yet.</p>');
|
|
72
|
+
if (step === 'searches') return rows(r.search_queries);
|
|
73
|
+
if (step === 'sources') return rows(r.sources);
|
|
74
|
+
if (step === 'brief') return r.instructions ? `<pre class="mwr-pre">${esc(r.instructions)}</pre>` : '<p class="mwr-empty">No brief yet.</p>';
|
|
75
|
+
if (step === 'scoring') {
|
|
76
|
+
const kinds = Object.keys(r.rubric || {}).sort();
|
|
77
|
+
if (!kinds.length) return '<p class="mwr-empty">Nothing is scored yet.</p>';
|
|
78
|
+
return kinds.map((kind) => `<p class="mwr-sub">${esc(kind === 'competitor' ? 'competitors' : kind === 'partner' ? 'partners' : kind)}</p>`
|
|
79
|
+
+ `<ul class="mwr-rows">${(r.rubric[kind] || []).map((d) => {
|
|
80
|
+
const w = Number.isInteger(d.weight) ? d.weight : 1;
|
|
81
|
+
return `<li class="mwr-row" title="${esc(d.definition || '')}"><span class="mwr-grow">${esc(d.label || d.key)}</span><span class="mwr-mono">×${esc(w)}</span></li>`;
|
|
82
|
+
}).join('')}</ul>`).join('');
|
|
83
|
+
}
|
|
84
|
+
// names: the people's list, not a rules field
|
|
85
|
+
const step3 = (view.steps || []).find((s) => s.key === 'names');
|
|
86
|
+
const top = (step3 && step3.rules && step3.rules.top) || [];
|
|
87
|
+
if (!top.length) return '<p class="mwr-empty">Nothing is watched yet.</p>';
|
|
88
|
+
const more = step3.value - top.length;
|
|
89
|
+
return `<ul class="mwr-rows">${top.map((t) => `<li class="mwr-row"><span class="mwr-grow">${esc(t.name)}</span>`
|
|
90
|
+
+ `<span class="mwr-pill${t.overall != null && t.overall >= 6 ? ' mwr-pill--hot' : ''}">${t.overall == null ? '' : esc(t.overall)}${t.kind === 'competitor' ? '' : ` · ${esc(t.kind)}`}</span></li>`).join('')}`
|
|
91
|
+
+ `${more > 0 ? `<li class="mwr-row mwr-faint">+ ${esc(more)} more</li>` : ''}</ul>`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// One open proposal. A card only for a reader who can act on it (the owner decides;
|
|
95
|
+
// where the government decides, anyone who may open a matter can send it to the vote);
|
|
96
|
+
// everyone else reads it as one line.
|
|
97
|
+
function proposalHtml(p, view, esc) {
|
|
98
|
+
const d = view.decide || {};
|
|
99
|
+
const nextV = view.active ? view.active.version + 1 : 1;
|
|
100
|
+
const q = /[?]$/.test(p.summary) ? p.summary : `${p.summary}?`;
|
|
101
|
+
const where = `proposal from ${p.by || 'a member'}`;
|
|
102
|
+
if (p.at_vote) {
|
|
103
|
+
return `<li class="mwr-row mwr-prop"><span class="mwr-dot" aria-hidden="true"></span><span class="mwr-grow">${esc(q)} <span class="mwr-faint">· ${esc(p.reason)}</span></span><span class="mwr-pill">at a vote</span></li>`;
|
|
104
|
+
}
|
|
105
|
+
const opts = [];
|
|
106
|
+
if (d.viewer_may_decide) {
|
|
107
|
+
opts.push({ label: 'Approve', result: p.kind === 'rule_change' ? `becomes v${nextV}` : 'takes effect now', rec: true, attrs: { 'data-act': 'approve', 'data-id': p.id } });
|
|
108
|
+
}
|
|
109
|
+
if (d.may_put_to_vote) opts.push({ label: 'Put to a vote', result: d.who ? `decided by ${d.who}` : 'the government decides', attrs: { 'data-act': 'vote', 'data-id': p.id } });
|
|
110
|
+
if (d.viewer_may_decide) opts.push({ label: 'Decline', result: 'stays in history', attrs: { 'data-act': 'decline', 'data-id': p.id } });
|
|
111
|
+
const ask = ASK();
|
|
112
|
+
if (!opts.length || !ask) {
|
|
113
|
+
return `<li class="mwr-row mwr-prop"><span class="mwr-dot" aria-hidden="true"></span><span class="mwr-grow">${esc(q)} <span class="mwr-faint">· ${esc(p.by || 'a member')}: ${esc(p.reason)}</span></span><span class="mwr-pill">waiting</span></li>`;
|
|
114
|
+
}
|
|
115
|
+
return ask.html({ tag: 'li', where, q, said: p.reason, row: true, opts, attrs: { 'data-proposal': p.id } });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function panelHtml(view, step, stop, esc) {
|
|
119
|
+
const s = (view.steps || []).find((x) => x.key === step);
|
|
120
|
+
if (!s) return '';
|
|
121
|
+
const props = (view.proposals || []).filter((p) => p.step === step);
|
|
122
|
+
// who we watch is not versioned with the rules, so the slider does not date it
|
|
123
|
+
const viewing = stop && stop.kind !== 'now' && step !== 'names' ? `<p class="mwr-faint mwr-viewing">as of ${esc(stopLabel(stop))}</p>` : '';
|
|
124
|
+
return `<h2 class="mwr-panel__h">${esc(s.title)}</h2>${viewing}`
|
|
125
|
+
+ rulesBodyHtml(step, stop ? stop.rules : null, view, esc)
|
|
126
|
+
+ (props.length ? `<ul class="mwr-props">${props.map((p) => proposalHtml(p, view, esc)).join('')}</ul>` : '')
|
|
127
|
+
+ `<div class="mwr-propose"><button type="button" class="mwr-btn" data-act="propose" data-step="${esc(step)}" aria-expanded="false">propose a change</button></div>`
|
|
128
|
+
+ '<div class="mwr-form-slot" id="mwr-form-slot"></div>';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The form for one step's change. Selects carry only values the server's own check
|
|
132
|
+
// accepts; the server checks again.
|
|
133
|
+
function formHtml(view, step, esc) {
|
|
134
|
+
const active = (view.history || []).find((h) => h.status === 'active');
|
|
135
|
+
const r = (active && active.rules) || {};
|
|
136
|
+
const opt = (v, label) => `<option value="${esc(v)}">${esc(label)}</option>`;
|
|
137
|
+
const reason = '<label class="mwr-field"><span>why</span><input name="reason" maxlength="500" required placeholder="one line"></label>';
|
|
138
|
+
let body = '';
|
|
139
|
+
if (step === 'searches' || step === 'sources') {
|
|
140
|
+
const list = step === 'searches' ? (r.search_queries || []) : (r.sources || []);
|
|
141
|
+
body = `<label class="mwr-field"><span>change</span><select name="op">${opt('add', step === 'searches' ? 'add a search' : 'add a source')}${list.length ? opt('remove', 'remove one') : ''}</select></label>`
|
|
142
|
+
+ `<label class="mwr-field" data-for="add"><span>${step === 'searches' ? 'search for' : 'read'}</span><input name="text" maxlength="300"></label>`
|
|
143
|
+
+ (list.length ? `<label class="mwr-field" data-for="remove" hidden><span>remove</span><select name="pick">${list.map((x) => opt(x, x)).join('')}</select></label>` : '');
|
|
144
|
+
} else if (step === 'names') {
|
|
145
|
+
body = `<label class="mwr-field"><span>name</span><input name="name" maxlength="200" required></label>`
|
|
146
|
+
+ `<label class="mwr-field"><span>kind</span><select name="kind">${opt('competitor', 'competitor')}${opt('partner', 'partner')}${opt('topic', 'topic')}</select></label>`
|
|
147
|
+
+ '<label class="mwr-field"><span>link</span><input name="url" type="url" maxlength="500" placeholder="https://"></label>'
|
|
148
|
+
+ '<label class="mwr-field"><span>why watch it</span><input name="why" maxlength="500"></label>';
|
|
149
|
+
} else if (step === 'scoring') {
|
|
150
|
+
const dims = [];
|
|
151
|
+
for (const kind of Object.keys(r.rubric || {}).sort()) {
|
|
152
|
+
for (const d of r.rubric[kind] || []) dims.push(opt(`${kind}|${d.key}`, `${d.label || d.key} (${kind})`));
|
|
153
|
+
}
|
|
154
|
+
body = `<label class="mwr-field"><span>score</span><select name="dim">${dims.join('')}</select></label>`
|
|
155
|
+
+ `<label class="mwr-field"><span>counts</span><select name="weight">${[0, 1, 2, 3].map((w) => opt(w, w ? `×${w}` : 'not at all')).join('')}</select></label>`;
|
|
156
|
+
} else if (step === 'brief') {
|
|
157
|
+
body = `<label class="mwr-field mwr-field--wide"><span>the brief</span><textarea name="text" rows="4" maxlength="4000">${esc(r.instructions || '')}</textarea></label>`;
|
|
158
|
+
}
|
|
159
|
+
return `<form class="mwr-form" data-step="${esc(step)}">${body}${reason}`
|
|
160
|
+
+ '<div class="mwr-form__go"><button type="submit" class="mwr-btn mwr-btn--go">send the proposal</button><button type="button" class="mwr-btn" data-act="cancel">cancel</button></div></form>';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The form's fields → the { step, change, reason } the server takes.
|
|
164
|
+
function proposalFromForm(step, f) {
|
|
165
|
+
const v = (k) => (f[k] == null ? '' : String(f[k]));
|
|
166
|
+
let change;
|
|
167
|
+
if (step === 'searches' || step === 'sources') change = v('op') === 'remove' ? { op: 'remove', text: v('pick') } : { op: 'add', text: v('text') };
|
|
168
|
+
else if (step === 'names') change = { op: 'add', name: v('name'), kind: v('kind'), url: v('url'), why: v('why') };
|
|
169
|
+
else if (step === 'scoring') {
|
|
170
|
+
const [kind, key] = v('dim').split('|');
|
|
171
|
+
change = { op: 'weight', kind, key, weight: Number(v('weight')) };
|
|
172
|
+
} else change = { op: 'set', text: v('text') };
|
|
173
|
+
return { step, change, reason: v('reason') };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function costHtml(cost, esc) {
|
|
177
|
+
if (!cost) return '';
|
|
178
|
+
const rows = cost.rows.map((r) => `<li class="mwr-row"><span class="mwr-grow">${esc(r.label)}</span><span class="mwr-mono">${esc(money(r.usd))}</span></li>`).join('');
|
|
179
|
+
const note = cost.basis === 'run'
|
|
180
|
+
? `run ${esc(cost.run && cost.run.n != null ? cost.run.n : '')} cost ${esc(money(cost.total))}${cost.run && cost.run.cost_log_usd != null ? ` (cost log ${esc(money(cost.run.cost_log_usd))})` : ''}; the split by step is an estimate`
|
|
181
|
+
: `estimate: no run has cost anything yet, so these are ${esc(money(cost.total))} from the planning figures`;
|
|
182
|
+
return `<ul class="mwr-rows">${rows}</ul><p class="mwr-note">${note}</p>`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const ERRORS = Object.freeze({
|
|
186
|
+
owner_only: 'Only the project owner can decide this.',
|
|
187
|
+
decided_by_sitting: 'The government decides this one. Put it to a vote.',
|
|
188
|
+
already_proposed: 'That change is already waiting.',
|
|
189
|
+
already_there: 'The rules already have that.',
|
|
190
|
+
not_there: 'The rules do not have that.',
|
|
191
|
+
no_change: 'That is how the rules already are.',
|
|
192
|
+
reason_required: 'Say why in one line.',
|
|
193
|
+
too_many_open: 'You have twenty proposals waiting. Wait for some to be decided.',
|
|
194
|
+
url_required: 'Add the link to its site.',
|
|
195
|
+
bad_url: 'That link does not look right.',
|
|
196
|
+
name_required: 'Give it a name.',
|
|
197
|
+
text_required: 'Write something first.',
|
|
198
|
+
no_longer_applies: 'The rules changed since this was proposed. Decline it and propose again.',
|
|
199
|
+
no_government: 'This project has no government; the owner decides.',
|
|
200
|
+
already_at_vote: 'It is already at a vote.',
|
|
201
|
+
});
|
|
202
|
+
const errorText = (code, fallback) => ERRORS[code] || fallback || 'That did not work. Try again.';
|
|
203
|
+
|
|
204
|
+
const lib = { stops, stopLabel, sliderHtml, pipelineHtml, rulesBodyHtml, proposalHtml, panelHtml, formHtml, proposalFromForm, costHtml, errorText };
|
|
205
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = lib;
|
|
206
|
+
if (root) root.OTBMarketWatchRules = lib;
|
|
207
|
+
})(typeof window !== 'undefined' ? window : null);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/* market-watch-rules.css — the Market Watch rules page (task 1004941), built to
|
|
2
|
+
docs/design/mocks/market-watch/rules.html, option C (the pipeline map), on the hall's
|
|
3
|
+
own tokens rather than the mockup's literal colours and fonts. Everything is under the
|
|
4
|
+
`mwr-` prefix; the proposal card is the hall's ask card (hall-ask.css). */
|
|
5
|
+
|
|
6
|
+
.mwr { max-width: 1000px; margin: 0 auto; padding: 4px 0 48px; color: var(--ink); }
|
|
7
|
+
.mwr-mono { font-family: var(--font-mono); font-size: 12.5px; }
|
|
8
|
+
.mwr-faint { color: var(--ink-faint); }
|
|
9
|
+
.mwr-empty { color: var(--ink-soft); margin: 8px 0; }
|
|
10
|
+
|
|
11
|
+
/* the version slider */
|
|
12
|
+
.mwr-time { display: flex; align-items: center; gap: 12px; margin-top: 6px; }
|
|
13
|
+
.mwr-time .mwr-mono { flex: none; white-space: nowrap; }
|
|
14
|
+
.mwr-time input { flex: 1; min-width: 0; height: 24px; margin: 0; accent-color: var(--accent); }
|
|
15
|
+
.mwr-ticks { display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-faint); padding: 0 4px; }
|
|
16
|
+
.mwr-pill { font-size: 12px; padding: 2px 9px; border-radius: 999px; border: 1px solid var(--border); color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 50%; }
|
|
17
|
+
.mwr-pill--hot { background: var(--accent-tint); border-color: transparent; color: var(--accent-ink); }
|
|
18
|
+
|
|
19
|
+
/* the pipeline: five steps you tap */
|
|
20
|
+
.mwr-pipe { display: flex; align-items: stretch; overflow-x: auto; padding: 14px 8px 14px 2px; margin-top: 8px; scrollbar-width: thin; }
|
|
21
|
+
.mwr-node { flex: none; width: 150px; border: 1px solid var(--border); border-radius: 18px; padding: 12px; background: var(--bg-card); color: var(--ink); cursor: pointer; text-align: left; position: relative; display: flex; flex-direction: column; gap: 4px; font: inherit; }
|
|
22
|
+
.mwr-node:hover { border-color: var(--border-strong); }
|
|
23
|
+
.mwr-node:focus-visible { outline: 2px solid var(--focus, var(--accent)); outline-offset: 3px; }
|
|
24
|
+
.mwr-node__k { font-size: 12px; color: var(--ink-faint); }
|
|
25
|
+
.mwr-node__v { font-size: 22px; font-weight: 300; letter-spacing: -0.02em; }
|
|
26
|
+
.mwr-node__s { font-size: 12.5px; color: var(--ink-soft); }
|
|
27
|
+
.mwr-node[aria-pressed="true"] { border-color: var(--ink); box-shadow: 0 0 0 1px var(--ink); }
|
|
28
|
+
.mwr-node--changed { background: var(--accent-tint); }
|
|
29
|
+
.mwr-badge { position: absolute; top: -7px; right: -7px; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 10px; background: var(--accent); color: var(--accent-on); font-size: 11px; font-weight: 600; display: grid; place-items: center; box-sizing: border-box; }
|
|
30
|
+
.mwr-arrow { flex: none; width: 28px; display: grid; place-items: center; color: var(--ink-faint); line-height: 1; }
|
|
31
|
+
/* on a phone the steps stack, and the arrow between them points down */
|
|
32
|
+
@media (max-width: 640px) {
|
|
33
|
+
.mwr-pipe { flex-direction: column; overflow-x: visible; padding-right: 8px; }
|
|
34
|
+
.mwr-node { width: 100%; flex-direction: row; align-items: baseline; gap: 10px; }
|
|
35
|
+
.mwr-node__v { font-size: 18px; }
|
|
36
|
+
.mwr-arrow { width: auto; height: 20px; font-size: 0; }
|
|
37
|
+
.mwr-arrow::after { content: "\2193"; font-size: 15px; line-height: 20px; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* the tapped step */
|
|
41
|
+
.mwr-panel { border: 1px solid var(--border); border-radius: 20px; padding: 16px; margin-top: 8px; background: var(--bg-card); }
|
|
42
|
+
.mwr-panel__h { margin: 0 0 8px; font-weight: 300; font-size: 21px; letter-spacing: -0.02em; }
|
|
43
|
+
.mwr-viewing { margin: -4px 0 8px; font-size: 13px; }
|
|
44
|
+
.mwr-sub { margin: 12px 0 4px; font-size: 12px; letter-spacing: 0.06em; color: var(--ink-faint); }
|
|
45
|
+
.mwr-pre { white-space: pre-wrap; font-family: var(--font-mono); font-size: 12.5px; color: var(--ink-soft); margin: 0; }
|
|
46
|
+
.mwr-rows { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--rule-soft, var(--border)); }
|
|
47
|
+
.mwr-row { display: flex; align-items: center; gap: 12px; min-height: 46px; padding: 8px 2px; border-bottom: 1px solid var(--rule-soft, var(--border)); white-space: nowrap; overflow: hidden; }
|
|
48
|
+
.mwr-grow { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
49
|
+
.mwr-props { list-style: none; margin: 14px 0 0; padding: 0; }
|
|
50
|
+
.mwr-prop .mwr-pill { flex: none; max-width: none; }
|
|
51
|
+
.mwr-dot { flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
|
|
52
|
+
.mwr-propose { margin-top: 12px; }
|
|
53
|
+
|
|
54
|
+
/* buttons and the proposal form */
|
|
55
|
+
.mwr-btn { border: 1px solid var(--border-strong); background: transparent; color: var(--ink); border-radius: 999px; padding: 7px 14px; cursor: pointer; font: inherit; font-weight: 500; font-size: 14px; white-space: nowrap; }
|
|
56
|
+
.mwr-btn:hover { background: var(--hask-chip-pick, var(--bg-frame)); }
|
|
57
|
+
.mwr-btn--go { background: var(--accent); border-color: var(--accent); color: var(--accent-on); }
|
|
58
|
+
.mwr-btn--go:hover { background: var(--accent); filter: brightness(1.05); }
|
|
59
|
+
.mwr-form { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px 12px; margin-top: 12px; }
|
|
60
|
+
.mwr-field { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--ink-soft); min-width: 0; }
|
|
61
|
+
.mwr-field--wide { grid-column: 1 / -1; }
|
|
62
|
+
.mwr-field[hidden] { display: none; }
|
|
63
|
+
.mwr-field input, .mwr-field select, .mwr-field textarea { font: inherit; font-size: 14px; color: var(--ink); background: var(--bg); border: 1px solid var(--border); border-radius: 10px; padding: 8px 10px; min-width: 0; box-sizing: border-box; width: 100%; }
|
|
64
|
+
.mwr-form__go { grid-column: 1 / -1; display: flex; gap: 8px; flex-wrap: wrap; }
|
|
65
|
+
|
|
66
|
+
/* the cost of one run */
|
|
67
|
+
.mwr-lbl { font-size: 12px; letter-spacing: 0.06em; color: var(--ink-faint); margin: 26px 0 8px; font-weight: 400; }
|
|
68
|
+
.mwr-note { margin: 8px 0 0; font-size: 12.5px; color: var(--ink-faint); }
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Market Watch rules — {{worldName}}</title>
|
|
7
|
+
<meta name="description" content="How the weekly market watch works, step by step, in plain words: what it searches, what it reads, who it watches, how it scores and what it writes. Propose a change with a reason.">
|
|
8
|
+
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
|
|
9
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
10
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
11
|
+
<link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
|
|
12
|
+
<link rel="stylesheet" href="/builders/hall-ask.css">
|
|
13
|
+
<link rel="stylesheet" href="/builders/market-watch-rules.css">
|
|
14
|
+
</head>
|
|
15
|
+
<body data-page="market-watch-rules" data-page-title="Market Watch rules">
|
|
16
|
+
|
|
17
|
+
<div class="app">
|
|
18
|
+
<aside class="app-sidebar" id="app-sidebar" aria-label="Primary"></aside>
|
|
19
|
+
<div class="app-scrim" id="app-scrim" hidden></div>
|
|
20
|
+
<div class="app-body">
|
|
21
|
+
<header class="app-topbar" id="app-topbar"></header>
|
|
22
|
+
<div class="app-content">
|
|
23
|
+
<!-- The page's one h1, for screen readers: no page shows its head (task 1004836). -->
|
|
24
|
+
<div class="page-head page-head--room">
|
|
25
|
+
<h1 class="page-head__h">Market Watch rules</h1>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<main id="hall-main" class="mwr">
|
|
29
|
+
<div id="mwr-slider"></div>
|
|
30
|
+
<div class="mwr-pipe" id="mwr-pipe" aria-label="the watch, step by step"></div>
|
|
31
|
+
<section class="mwr-panel" id="mwr-panel" aria-live="polite"><p class="mwr-empty">Loading the watch rules…</p></section>
|
|
32
|
+
<h2 class="mwr-lbl">cost of one run, by step</h2>
|
|
33
|
+
<div id="mwr-cost"></div>
|
|
34
|
+
</main>
|
|
35
|
+
|
|
36
|
+
<footer class="app-foot" id="app-foot"></footer>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
|
|
42
|
+
|
|
43
|
+
<script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
|
|
44
|
+
<script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
|
|
45
|
+
<script src="/builders/craft-mode.js?v=2026-10-09-living3"></script>
|
|
46
|
+
<script src="/builders/shell.js?v=2026-10-25-escape"></script>
|
|
47
|
+
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
48
|
+
<script src="/builders/palette.js" defer></script>
|
|
49
|
+
<script src="/builders/hall-kit.js"></script>
|
|
50
|
+
<script src="/builders/hall-ask.js"></script>
|
|
51
|
+
<script src="/builders/market-watch-rules-lib.js"></script>
|
|
52
|
+
<script src="/builders/market-watch-rules.js"></script>
|
|
53
|
+
</body>
|
|
54
|
+
</html>
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// market-watch-rules.js — the Market Watch rules page (/builders/market-watch-rules,
|
|
2
|
+
// task 1004941). One read, GET /api/bongos/market-watch/rules; the markup is
|
|
3
|
+
// OTBMarketWatchRules's (market-watch-rules-lib.js). Three writes, each checked again on
|
|
4
|
+
// the server: a member's proposal, the owner's approve or decline, and putting a proposal
|
|
5
|
+
// to the government's vote.
|
|
6
|
+
|
|
7
|
+
(() => {
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const lib = window.OTBMarketWatchRules;
|
|
11
|
+
const esc = (s) => window.OTB.escapeHtml(s);
|
|
12
|
+
const toast = (m) => window.OTB.toast(m);
|
|
13
|
+
const API = '/api/bongos/market-watch';
|
|
14
|
+
const $ = (id) => document.getElementById(id);
|
|
15
|
+
|
|
16
|
+
let view = null;
|
|
17
|
+
let step = 'searches';
|
|
18
|
+
let at = 0;
|
|
19
|
+
|
|
20
|
+
async function send(method, url, body) {
|
|
21
|
+
const r = await fetch(url, {
|
|
22
|
+
method,
|
|
23
|
+
credentials: 'same-origin',
|
|
24
|
+
headers: body ? { 'content-type': 'application/json' } : {},
|
|
25
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
26
|
+
});
|
|
27
|
+
let data = null;
|
|
28
|
+
try { data = await r.json(); } catch (_) { /* no body */ }
|
|
29
|
+
return { ok: r.ok, status: r.status, data };
|
|
30
|
+
}
|
|
31
|
+
const codeOf = (data) => (window.OTB.errCode ? window.OTB.errCode(data) : (data && data.error && data.error.code));
|
|
32
|
+
|
|
33
|
+
function stopNow() {
|
|
34
|
+
const s = lib.stops(view);
|
|
35
|
+
const i = s.findIndex((x) => x.kind === 'now');
|
|
36
|
+
return i === -1 ? Math.max(0, s.length - 1) : i;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function render() {
|
|
40
|
+
const stops = lib.stops(view);
|
|
41
|
+
const stop = stops[at] || null;
|
|
42
|
+
$('mwr-slider').innerHTML = lib.sliderHtml(view, at, esc);
|
|
43
|
+
$('mwr-pipe').innerHTML = lib.pipelineHtml(view, { open: step, changed: stop ? stop.changed : [] }, esc);
|
|
44
|
+
$('mwr-panel').innerHTML = lib.panelHtml(view, step, stop, esc);
|
|
45
|
+
$('mwr-cost').innerHTML = lib.costHtml(view.cost, esc);
|
|
46
|
+
const slider = $('mwr-v');
|
|
47
|
+
if (slider) slider.addEventListener('input', () => { at = Number(slider.value); render(); $('mwr-v').focus(); });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function load() {
|
|
51
|
+
const r = await send('GET', `${API}/rules`);
|
|
52
|
+
if (!r.ok) {
|
|
53
|
+
const msg = r.status === 404
|
|
54
|
+
? 'Market Watch is off on this project. The owner turns it on in Modules.'
|
|
55
|
+
: 'Could not load the watch rules. Try reloading the page.';
|
|
56
|
+
$('mwr-pipe').innerHTML = '';
|
|
57
|
+
$('mwr-panel').innerHTML = `<p class="mwr-empty">${esc(msg)}</p>`;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
view = r.data;
|
|
61
|
+
if (!view.steps || !view.history || !view.history.length) {
|
|
62
|
+
$('mwr-panel').innerHTML = '<p class="mwr-empty">This project has no watch rules yet.</p>';
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
at = stopNow();
|
|
66
|
+
render();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function openForm(btn) {
|
|
70
|
+
const slot = $('mwr-form-slot');
|
|
71
|
+
if (!slot) return;
|
|
72
|
+
if (slot.firstChild) { slot.innerHTML = ''; btn.setAttribute('aria-expanded', 'false'); return; }
|
|
73
|
+
slot.innerHTML = lib.formHtml(view, step, esc);
|
|
74
|
+
btn.setAttribute('aria-expanded', 'true');
|
|
75
|
+
const form = slot.querySelector('form');
|
|
76
|
+
const op = form.querySelector('select[name="op"]');
|
|
77
|
+
if (op) {
|
|
78
|
+
op.addEventListener('change', () => {
|
|
79
|
+
form.querySelectorAll('[data-for]').forEach((el) => { el.hidden = el.dataset.for !== op.value; });
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
const first = form.querySelector('input, select, textarea');
|
|
83
|
+
if (first) first.focus();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function submitForm(form) {
|
|
87
|
+
const fields = Object.fromEntries(new FormData(form).entries());
|
|
88
|
+
const body = lib.proposalFromForm(form.dataset.step, fields);
|
|
89
|
+
const btn = form.querySelector('button[type="submit"]');
|
|
90
|
+
btn.disabled = true;
|
|
91
|
+
const r = await send('POST', `${API}/rules/proposals`, body);
|
|
92
|
+
btn.disabled = false;
|
|
93
|
+
if (!r.ok) return toast(lib.errorText(codeOf(r.data)));
|
|
94
|
+
toast('proposal sent');
|
|
95
|
+
await load();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function act(kind, id) {
|
|
99
|
+
const url = kind === 'vote' ? `${API}/proposals/${id}/vote` : `${API}/proposals/${id}/decide`;
|
|
100
|
+
const r = await send('POST', url, kind === 'vote' ? undefined : { decision: kind });
|
|
101
|
+
if (!r.ok) return toast(lib.errorText(codeOf(r.data)));
|
|
102
|
+
if (kind === 'approve') toast(r.data && r.data.version ? `approved · v${r.data.version.version}` : 'approved');
|
|
103
|
+
else toast(kind === 'vote' ? 'sent to a vote' : 'declined');
|
|
104
|
+
await load();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
document.addEventListener('click', (e) => {
|
|
108
|
+
if (!view) return;
|
|
109
|
+
const node = e.target.closest('.mwr-node');
|
|
110
|
+
if (node) { step = node.dataset.step; render(); return; }
|
|
111
|
+
const b = e.target.closest('[data-act]');
|
|
112
|
+
if (!b) return;
|
|
113
|
+
const what = b.dataset.act;
|
|
114
|
+
if (what === 'propose') return openForm(b);
|
|
115
|
+
if (what === 'cancel') { $('mwr-form-slot').innerHTML = ''; return; }
|
|
116
|
+
if (what === 'approve' || what === 'decline' || what === 'vote') {
|
|
117
|
+
b.disabled = true;
|
|
118
|
+
act(what, b.dataset.id).finally(() => { b.disabled = false; });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
document.addEventListener('submit', (e) => {
|
|
122
|
+
const form = e.target.closest('.mwr-form');
|
|
123
|
+
if (!form) return;
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
submitForm(form);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
load();
|
|
129
|
+
})();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_": "The Market Watch rules page (task 1004941), built to docs/design/mocks/market-watch/rules.html, option C (the pipeline map). One read, GET /market-watch/rules. Against the kit's own stub it 404s, so the page renders its 'Market Watch is off' state, which the floors must hold too. TO SEE IT POPULATED, point the kit at the hall-preview harness: `node scripts/hall-preview/server.js --port 4641 --fixture-me`, then `render.js --page /builders/market-watch-rules --base-in http://builders.localhost:4641 --base-out http://builders.localhost:4641`. The harness answers from fixtures/market-watch__rules.json, built by the real composers (rules-view.buildView over the seed's rules and 60 items, two approved changes and four open proposals). Steps are opened with clickIfVisible because against the stub the nodes do not exist.",
|
|
3
|
+
"page": "/builders/market-watch-rules",
|
|
4
|
+
"surface": "hall-ui",
|
|
5
|
+
"stub": { "prefix": "/builders/" },
|
|
6
|
+
"modeQuery": false,
|
|
7
|
+
"ignoreRequests": ["/api/(gds|bongos)/me$", "/api/(gds|bongos)/me/", "/api/(gds|bongos)/goals", "/api/(gds|bongos)/inbox/", "/api/(gds|bongos)/market-watch/", "/api/(gds|bongos)/core-update"],
|
|
8
|
+
"ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
|
|
9
|
+
"states": {
|
|
10
|
+
"in": { "auth": true, "actions": [["wait", 700], ["clickIfVisible", "[data-craft-close]"], ["wait", 150]], "expect": { "visible": ["#mwr-panel"] } },
|
|
11
|
+
"scoring": { "auth": true, "actions": [["wait", 700], ["clickIfVisible", "[data-craft-close]"], ["wait", 150], ["clickIfVisible", ".mwr-node[data-step='scoring']"], ["wait", 200]] },
|
|
12
|
+
"names": { "auth": true, "actions": [["wait", 700], ["clickIfVisible", "[data-craft-close]"], ["wait", 150], ["clickIfVisible", ".mwr-node[data-step='names']"], ["wait", 200]] },
|
|
13
|
+
"propose": { "auth": true, "actions": [["wait", 700], ["clickIfVisible", "[data-craft-close]"], ["wait", 150], ["clickIfVisible", ".mwr-node[data-step='sources']"], ["wait", 150], ["clickIfVisible", "[data-act='propose']"], ["wait", 200]] },
|
|
14
|
+
"out": { "auth": false }
|
|
15
|
+
},
|
|
16
|
+
"reducedMotion": { "auth": true, "actions": [["wait", 700], ["clickIfVisible", "[data-craft-close]"], ["wait", 150]] }
|
|
17
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/market-watch/matter-acts.js — what a PASSED government vote on a Market Watch
|
|
4
|
+
// proposal carries out (task 1004941, goal 1000137).
|
|
5
|
+
//
|
|
6
|
+
// Where the project's government hands its Rules building to a sitting (the
|
|
7
|
+
// decision-rules table, ADR 0363), the owner cannot approve a proposal alone: the decide
|
|
8
|
+
// route refuses with 409 decided_by_sitting, and the proposal is put to a vote instead
|
|
9
|
+
// (POST /market-watch/proposals/:id/vote, which files a Rules matter through the
|
|
10
|
+
// government port). When that sitting passes the matter, the government module calls
|
|
11
|
+
// this act through the 'governor.matterAct' contribution point, after the close commits,
|
|
12
|
+
// and it approves the proposal through the SAME decideProposal the owner's route calls,
|
|
13
|
+
// so a vote can do nothing the owner's approval could not. A returned matter acts on
|
|
14
|
+
// nothing; the proposal stays open for the owner to decline.
|
|
15
|
+
//
|
|
16
|
+
// A module never requires a sibling: the government reaches this only through the
|
|
17
|
+
// contribution, and this reaches the government only through its port.
|
|
18
|
+
|
|
19
|
+
const api = require('../../src/module-api');
|
|
20
|
+
const proposals = require('./proposals');
|
|
21
|
+
const { projectKey } = require('./seed');
|
|
22
|
+
|
|
23
|
+
const log = api.logger('market-watch');
|
|
24
|
+
const POINT = 'governor.matterAct';
|
|
25
|
+
const AREA = 'rules';
|
|
26
|
+
const TYPE = 'market_watch_proposal';
|
|
27
|
+
|
|
28
|
+
// Where the act finds the project and its transaction. A test (and the government walk)
|
|
29
|
+
// replaces these to run the act without a database or branding.
|
|
30
|
+
const runtime = {
|
|
31
|
+
project: () => projectKey(api.branding().domains.publicOrigin),
|
|
32
|
+
withTx: (fn) => api.withTx(fn, { pool: api.pool }),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
async function approveByVote(action, ctx) {
|
|
36
|
+
const project = runtime.project();
|
|
37
|
+
if (!project) return { done: false, reason: 'no_project' };
|
|
38
|
+
const withTx = runtime.withTx;
|
|
39
|
+
const out = await proposals.decideProposal({
|
|
40
|
+
project,
|
|
41
|
+
id: action.proposal_id,
|
|
42
|
+
decision: 'approve',
|
|
43
|
+
decidedBy: ctx && ctx.decidedBy,
|
|
44
|
+
via: { decided_via: 'vote', matter_id: ctx && ctx.matterId != null ? String(ctx.matterId) : null },
|
|
45
|
+
withTx,
|
|
46
|
+
});
|
|
47
|
+
if (!out.ok) {
|
|
48
|
+
log.warn(`[market-watch] proposal ${action.proposal_id} passed by vote (matter ${ctx && ctx.matterId}) but was not applied: ${out.reason}`);
|
|
49
|
+
return { done: false, reason: out.reason };
|
|
50
|
+
}
|
|
51
|
+
log.info(`[market-watch] proposal ${action.proposal_id} ${out.status} by vote (matter ${ctx && ctx.matterId})`);
|
|
52
|
+
return { done: true };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function contributeMarketWatchMatterActs() {
|
|
56
|
+
api.contribute(POINT, { area: AREA, type: TYPE, act: (action, ctx) => approveByVote(action, ctx) });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { approveByVote, contributeMarketWatchMatterActs, runtime };
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"key": "market-watch",
|
|
3
3
|
"title": "Market Watch (a weekly AI watch on a project's market)",
|
|
4
|
-
"description": "A weekly AI watch on a project's competitors, partners and topics, with a sourced log per tracked item and a weekly question to members (goal 1000137). Its data model (tables, read queries and the hub seed of the 2026-10-28 research)
|
|
5
|
-
"version": "0.
|
|
6
|
-
"coreVersion": "^1.
|
|
4
|
+
"description": "A weekly AI watch on a project's competitors, partners and topics, with a sourced log per tracked item and a weekly question to members (goal 1000137). Its data model (tables, read queries and the hub seed of the 2026-10-28 research), its weekly run (an hourly poller that researches each tracked item on the project's own Anthropic key, under the monthly cap), the weekly ask card in the hall's attention inbox (task 1004940) and its rules page (the watch rules in plain words, members' proposed changes, and the owner's or the government's decision on each, task 1004941) are in. Off by default, and it cannot be turned on behind an open join door with no payer, because every run will spend the project's AI budget.",
|
|
5
|
+
"version": "0.4.0",
|
|
6
|
+
"coreVersion": "^1.22.0",
|
|
7
7
|
"category": {
|
|
8
8
|
"parent": "knowledge"
|
|
9
9
|
},
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"contributes": {
|
|
15
15
|
"migrations": true,
|
|
16
16
|
"routes": [
|
|
17
|
-
"ask"
|
|
17
|
+
"ask",
|
|
18
|
+
"rules"
|
|
18
19
|
],
|
|
19
20
|
"pollers": [
|
|
20
21
|
"hub-seed",
|