@bongos/core 1.24.3 → 1.24.5

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 (76) hide show
  1. package/.bongos-core.json +142 -67
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +8 -0
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/config/branding.neutral.json +0 -1
  8. package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +11 -0
  9. package/docs/api/openapi.json +121 -4
  10. package/docs/api-reference.md +8 -1
  11. package/docs/branding-contract.md +1 -1
  12. package/docs/copy-inventory.md +11 -11
  13. package/docs/copy-registry.json +12 -12
  14. package/docs/file-map.md +3 -2
  15. package/docs/module-api-changelog.md +2 -2
  16. package/docs/page-readings.json +1 -1
  17. package/modules/copy-desk/auto-close.js +46 -8
  18. package/modules/copy-desk/page-status.js +11 -2
  19. package/modules/copy-desk/tests/copy_no_cms.mjs +8 -1
  20. package/modules/copy-desk/tests/copy_page_auto_close.mjs +64 -2
  21. package/modules/copy-desk/tests/copy_page_status.mjs +5 -1
  22. package/modules/discord/ship-broadcast.js +7 -0
  23. package/modules/economy/credit-wording.js +93 -0
  24. package/modules/economy/credits.js +26 -12
  25. package/modules/economy/reward-policy.js +49 -3
  26. package/modules/economy/reward.js +5 -0
  27. package/modules/government/routes/government.js +7 -0
  28. package/modules/hall-ui/public/tweak-editor.js +2 -1
  29. package/modules/ideas/routes/inbox.js +6 -0
  30. package/modules/lifecycle/db-claims.js +2 -2
  31. package/modules/lifecycle/db-ship.js +2 -2
  32. package/modules/lifecycle/routes/project-settings.js +6 -4
  33. package/modules/market-watch/ask.js +425 -0
  34. package/modules/market-watch/migrations/market_watch_003_ask_pool.sql +21 -0
  35. package/modules/market-watch/module.json +10 -3
  36. package/modules/market-watch/pollers/weekly-ask.js +89 -0
  37. package/modules/market-watch/public/hall-widget.js +271 -0
  38. package/modules/market-watch/public/weekly-ask.css +39 -0
  39. package/modules/market-watch/routes/ask.js +95 -0
  40. package/modules/onboarding/routes/access-requests.js +38 -10
  41. package/modules/platform-identity/account-logins.js +13 -0
  42. package/modules/platform-identity/routes/my-projects.js +10 -7
  43. package/modules/platform-identity/routes/sso.js +11 -6
  44. package/package-lock.json +2 -2
  45. package/package.json +1 -1
  46. package/release-notes.json +37 -1
  47. package/scripts/bongos/backfill-subagent-rewards.js +7 -7
  48. package/scripts/bongos/codemod-rename-scripts-gds.js +1 -3
  49. package/scripts/bongos/dev-box-guard.js +0 -1
  50. package/scripts/bongos/publish-manifest.js +1 -1
  51. package/scripts/bongos/run-unit-tests.js +4 -0
  52. package/scripts/hall-preview/server.js +13 -0
  53. package/src/bongos/agent-standing-rules.js +44 -0
  54. package/src/bongos/auth-idp.js +17 -5
  55. package/src/bongos/db-kernel.js +14 -1
  56. package/src/bongos/module-scope-map.js +4 -2
  57. package/src/bongos/routes/me.js +4 -1
  58. package/src/module-api.js +1 -1
  59. package/tests/account_logins.mjs +6 -4
  60. package/tests/agent_standing_rules.mjs +142 -0
  61. package/tests/backfill_subagent_rewards.mjs +7 -7
  62. package/tests/claims_batch_routes.mjs +36 -13
  63. package/tests/copy_stuck_page_tweaks.mjs +3 -1
  64. package/tests/credit_grant.mjs +7 -7
  65. package/tests/credit_wording.mjs +154 -0
  66. package/tests/google_halls.mjs +211 -0
  67. package/tests/government_laws_session.mjs +2 -1
  68. package/tests/hall_tweak_editor.mjs +11 -0
  69. package/tests/host_topology_skips.mjs +2 -0
  70. package/tests/market_watch_ask.mjs +369 -0
  71. package/tests/market_watch_ask_db.mjs +122 -0
  72. package/tests/market_watch_data_db.mjs +4 -0
  73. package/tests/market_watch_module.mjs +9 -5
  74. package/tests/project_settings_page.mjs +1 -1
  75. package/tests/reward_cost_scale.mjs +200 -0
  76. package/tests/run_node_reroute.mjs +136 -0
@@ -16,7 +16,7 @@ const log = api.logger('lifecycle');
16
16
  const seams = api;
17
17
  const doneWhen = require('./done-when.js');
18
18
  const { THETES_GRADUATION_THRESHOLD, maybeAutoGraduateToThetes, foldWorktreeName } = require('./db-rank-authz.js');
19
- const { mostRecentClaimHolderId, sealSessionLog, staleClaimHourPredicate } = require('./db-shared.js');
19
+ const { idKey, mostRecentClaimHolderId, sealSessionLog, staleClaimHourPredicate } = require('./db-shared.js');
20
20
  const { LIVE_STATUSES } = require('./dead-deps.js');
21
21
  const { isPageTweak } = require('./page-tweak-hold.js');
22
22
  const pageTweakArtist = require('./page-tweak-artist.js');
@@ -138,7 +138,7 @@ async function resolveClaim({ claimId, builderId, outcome, notesMd, valueSummary
138
138
  );
139
139
  if (claimRows.length === 0) throw { code: 'CLAIM_NOT_FOUND' };
140
140
  const claim = claimRows[0];
141
- if (claim.builder_id !== builderId) throw { code: 'NOT_YOUR_CLAIM' };
141
+ if (idKey(claim.builder_id) !== idKey(builderId)) throw { code: 'NOT_YOUR_CLAIM' }; // task 1005004: pg bigint string vs a number
142
142
  if (claim.released_at) throw { code: 'ALREADY_RESOLVED' };
143
143
  // Resolve lock: only the WORKING TREE that created this claim can resolve it
144
144
  // (task 1003767) — falling back to the creating session where no tree is
@@ -114,8 +114,10 @@ function creditRow() {
114
114
  };
115
115
  }
116
116
  const mult = (1 + Number(policy.marginPct) / 100).toFixed(2);
117
- const peg = Number(policy.usdPeg);
118
- const pegNote = peg !== 1 ? ` × ${peg} peg` : '';
117
+ // `costScale` (task 1004968; formerly `usdPeg`) is a sizing factor, not an
118
+ // exchange rate — so the sentence says "scale", never "peg" (ADR 0387).
119
+ const scale = Number(policy.costScale);
120
+ const pegNote = Number.isFinite(scale) && scale !== 1 ? ` × ${scale} scale` : '';
119
121
  // The per-task estimate is ADVERTISED but never paid when the mode is cost-plus-only
120
122
  // (ADR 0146). A page that showed the estimate without that sentence would be telling
121
123
  // a builder they will earn a number this instance has decided not to pay.
@@ -129,7 +131,7 @@ function creditRow() {
129
131
  title: 'Credit calculation',
130
132
  editable: false,
131
133
  state: 'live',
132
- value: { marginPct: policy.marginPct, usdPeg: policy.usdPeg, mode: policy.mode, perTaskCredit: policy.perTaskCredit },
134
+ value: { marginPct: policy.marginPct, costScale: policy.costScale, mode: policy.mode, perTaskCredit: policy.perTaskCredit },
133
135
  sentence: `A shipped session earns round(true cost in USD × ${mult}${pegNote}) credits, sized by the session’s cost in USD. Credits measure contribution; they are not money. ${estimate}`,
134
136
  source: 'branding pack → `reward`',
135
137
  reads: { file: 'config/branding.json → reward', adr: 'ADR 0054, ADR 0146' },
@@ -246,7 +248,7 @@ module.exports = function buildProjectSettingsRouter() {
246
248
  // rank: project.settings.manage (metic+, government_010), the SAME floor as the page
247
249
  // it serves (requireGovernmentPage). The first cut gated this any-builder on the
248
250
  // reasoning that it is "the project's own published terms" — but the payload also
249
- // carries reward-policy internals (marginPct, usdPeg, mode), a raw rejected
251
+ // carries reward-policy internals (marginPct, costScale, mode), a raw rejected
250
252
  // project_settings value, and internal file/migration paths, and a xenos who cannot
251
253
  // even load the page could still curl those. Matching the read floor to the page's
252
254
  // (task 1003280 grade, hacker lens) closes that asymmetry; the two WRITES already
@@ -0,0 +1,425 @@
1
+ 'use strict';
2
+
3
+ // modules/market-watch/ask.js — the weekly ask card (task 1004940, goal 1000137).
4
+ //
5
+ // Once a week every member of a project with Market Watch on gets one short card with two
6
+ // questions: "what counts as a competitor?" and "what else should we watch?". Each shows
7
+ // chips drawn from the project's OWN tracked items, plus a free "name one" / "add a topic"
8
+ // entry. Design of record: docs/design/mocks/market-watch/weekly-ask.html (option A).
9
+ //
10
+ // issueWeeklyAsk the poller's half (pollers/weekly-ask.js): this week's two question rows,
11
+ // created once per project per week by the UNIQUE (project, week_of,
12
+ // question_key) constraint, so a second tick or a second process is a no-op
13
+ // cardFor the member's card, or null: no ask this week, the ask has closed, or
14
+ // they already answered. A closed ask is simply not shown — it expires
15
+ // quietly, and nothing ever reminds anyone (the owner's "no nagging")
16
+ // answer a member's taps and entries, stored as market_watch_ask_answers rows
17
+ // and POOLED into proposals
18
+ //
19
+ // POOLING. Similar answers are the same name or topic once normalised (`normalise`: case,
20
+ // accents' width forms, punctuation and a leading article do not count). The pool key is
21
+ // `<question>|<normalised choice>`. The first answer with a key files ONE
22
+ // market_watch_proposals row (origin 'weekly_ask', answer_id = that answer, payload.pool_key
23
+ // = the key); every later answer with the key joins it instead. The agree count is DERIVED
24
+ // (the distinct members whose answers carry the key), never a stored number to keep true.
25
+ // The pool lock is a transaction-scoped advisory lock on the key, so two members tapping
26
+ // the same chip at once still file one proposal.
27
+ //
28
+ // What each answer proposes:
29
+ // a competitor chip ("AI app builder") rule_change: count that kind of company as a
30
+ // competitor (it points at the rules version in use;
31
+ // with no rules in use there is nothing to change,
32
+ // so the answer is kept and files no proposal)
33
+ // "name one" ("Kilo Code") add_item: track it as a competitor
34
+ // a watch chip or "add a topic" add_item: track it as a topic
35
+ //
36
+ // UNTRUSTED TEXT. Free entries are a member's words: cleaned (control characters out,
37
+ // whitespace collapsed), capped at MAX_TEXT, and only ever escaped where they are drawn
38
+ // (public/hall-widget.js). They never reach an AI prompt from here.
39
+ //
40
+ // Everything below the pure helpers takes a `store` (createAskStore(pool) for Postgres),
41
+ // so the whole flow is tested with an in-memory store and no database.
42
+
43
+ const DAY_MS = 24 * 60 * 60 * 1000;
44
+ const MAX_TEXT = 80; // a name or a topic, not a paragraph
45
+ const MAX_PICKS = 20; // more chips than both questions ever show
46
+ const MAX_NAMES = 6; // free entries per answer
47
+ const RIVAL_CHIPS = 5;
48
+ const WATCH_CHIPS = 4;
49
+ const NOTHING = '~nothing'; // "Nothing new this week": normalise() never yields a '~'
50
+
51
+ const QUESTIONS = Object.freeze([
52
+ Object.freeze({ key: 'rival', prompt: 'what counts as a competitor?' }),
53
+ Object.freeze({ key: 'watch', prompt: 'what else should we watch?' }),
54
+ ]);
55
+ const QUESTION_KEYS = Object.freeze(QUESTIONS.map((q) => q.key));
56
+
57
+ // ---- pure helpers ---------------------------------------------------------
58
+
59
+ // weekStart(now) → 'YYYY-MM-DD', the Monday (UTC) of now's week: the ask's week_of.
60
+ function weekStart(now = new Date()) {
61
+ const d = new Date(now);
62
+ const day = (d.getUTCDay() + 6) % 7; // Monday 0 … Sunday 6
63
+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - day)).toISOString().slice(0, 10);
64
+ }
65
+
66
+ // isoWeek(weekOf) → the ISO week number the card names ("week 44").
67
+ function isoWeek(weekOf) {
68
+ const d = new Date(`${String(weekOf).slice(0, 10)}T00:00:00Z`);
69
+ const thursday = new Date(d.getTime() + (3 - ((d.getUTCDay() + 6) % 7)) * DAY_MS);
70
+ const jan1 = Date.UTC(thursday.getUTCFullYear(), 0, 1);
71
+ return 1 + Math.floor((thursday.getTime() - jan1) / (7 * DAY_MS));
72
+ }
73
+
74
+ const dateOnly = (v) => (v instanceof Date ? v.toISOString().slice(0, 10) : String(v).slice(0, 10));
75
+ const addDays = (weekOf, n) => new Date(Date.parse(`${dateOnly(weekOf)}T00:00:00Z`) + n * DAY_MS);
76
+
77
+ // Control characters, zero-width marks and direction overrides: never part of a name, and
78
+ // a direction override can make one entry read as another.
79
+ const HIDDEN = [[0x00, 0x1f], [0x7f, 0x9f], [0x200b, 0x200f], [0x2028, 0x202e], [0x2066, 0x2069], [0xfeff, 0xfeff]];
80
+ const isHidden = (c) => HIDDEN.some(([a, b]) => c >= a && c <= b);
81
+
82
+ // cleanText(s) → a member's entry as it may be stored and shown, or '' for nothing.
83
+ function cleanText(s) {
84
+ if (typeof s !== 'string') return '';
85
+ let out = '';
86
+ for (const ch of s.normalize('NFKC')) out += isHidden(ch.codePointAt(0)) ? ' ' : ch;
87
+ return out
88
+ .replace(/\s+/g, ' ').trim();
89
+ }
90
+
91
+ // normalise(s) → the pooling key: lower case, letters and digits only, one space between
92
+ // words, no leading article. "Kilo Code", " kilo-code ", "The Kilo Code!" pool together.
93
+ function normalise(s) {
94
+ return cleanText(String(s == null ? '' : s)).toLowerCase()
95
+ .replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
96
+ .replace(/^(the|a|an) (?=\S)/, '')
97
+ .slice(0, MAX_TEXT);
98
+ }
99
+
100
+ const poolKey = (question, choice) => `${question}|${choice}`;
101
+
102
+ // shortName("Cognition (Devin)") → "Cognition": a chip's example stays short.
103
+ const shortName = (n) => String(n || '').replace(/\s*\(.*\)\s*$/, '').split(' / ')[0].trim();
104
+ const capFirst = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
105
+
106
+ function topCategories(items, limit, skip = new Set()) {
107
+ const by = new Map();
108
+ for (const it of items) {
109
+ const label = cleanText(it.category || '');
110
+ if (!label) continue;
111
+ const k = normalise(label);
112
+ if (!k || skip.has(k)) continue;
113
+ if (!by.has(k)) by.set(k, { label: capFirst(label).slice(0, 48), names: [] });
114
+ by.get(k).names.push(shortName(it.name));
115
+ }
116
+ return [...by.values()]
117
+ .sort((a, b) => b.names.length - a.names.length || a.label.localeCompare(b.label))
118
+ .slice(0, limit)
119
+ .map((c) => ({ label: c.label, example: c.names.filter(Boolean).slice(0, 2).join(', ').slice(0, 48) }));
120
+ }
121
+
122
+ // chipOptions(items) → { rival: [...], watch: [...] }, each chip { label, example }, drawn
123
+ // from the project's own active items. Competitor chips are the kinds of company it already
124
+ // tracks as competitors, most-tracked first. Watch chips are the areas its partners sit in
125
+ // that it does not already watch as a topic or ask about as a competitor kind.
126
+ function chipOptions(items) {
127
+ const list = (items || []).filter((i) => i && i.status !== 'removed');
128
+ const rival = topCategories(list.filter((i) => i.kind === 'competitor'), RIVAL_CHIPS);
129
+ const skip = new Set([
130
+ ...rival.map((c) => normalise(c.label)),
131
+ ...list.filter((i) => i.kind === 'topic').map((i) => normalise(i.name)),
132
+ ]);
133
+ const watch = topCategories(list.filter((i) => i.kind === 'partner'), WATCH_CHIPS, skip);
134
+ return { rival, watch };
135
+ }
136
+
137
+ // parseAnswer(body) → { entries, nothing } or { error, status }. An entry is
138
+ // { question, choice (the normalised key), label (what it shows as), free (bool) }.
139
+ // Chips are matched against the ask's own options in answer(); here only the shape.
140
+ function parseAnswer(body) {
141
+ const bad = (code, message) => ({ error: code, status: 400, message });
142
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return bad('bad_answer', 'An answer is an object.');
143
+ const picks = body.picks == null ? [] : body.picks;
144
+ const names = body.names == null ? [] : body.names;
145
+ if (!Array.isArray(picks) || !Array.isArray(names)) return bad('bad_answer', 'picks and names are lists.');
146
+ if (picks.length > MAX_PICKS || names.length > MAX_NAMES) return bad('too_many', 'That is more than the card offers.');
147
+ const entries = [];
148
+ for (const [list, free] of [[picks, false], [names, true]]) {
149
+ for (const e of list) {
150
+ if (!e || typeof e !== 'object') return bad('bad_answer', 'Each answer names its question.');
151
+ const question = e.question;
152
+ if (!QUESTION_KEYS.includes(question)) return bad('unknown_question', 'There is no such question on this card.');
153
+ const raw = typeof e[free ? 'text' : 'choice'] === 'string' ? e[free ? 'text' : 'choice'] : '';
154
+ if (raw.length > MAX_TEXT * 4) return bad('text_too_long', `Keep it under ${MAX_TEXT} characters.`);
155
+ const label = cleanText(raw);
156
+ if (label.length > MAX_TEXT) return bad('text_too_long', `Keep it under ${MAX_TEXT} characters.`);
157
+ const choice = normalise(label);
158
+ if (!choice) return bad('empty_answer', 'An entry needs a name or a topic.');
159
+ entries.push({ question, choice, label, free });
160
+ }
161
+ }
162
+ const nothing = body.nothing === true;
163
+ if (!entries.length && !nothing) return bad('empty_answer', 'Pick at least one, or say nothing new this week.');
164
+ return { entries, nothing: nothing && !entries.length };
165
+ }
166
+
167
+ // proposalFor(entry, ruleVersionId) → the proposal a NEW pool files, or null.
168
+ function proposalFor(entry, ruleVersionId) {
169
+ if (entry.question === 'rival' && !entry.free) {
170
+ if (ruleVersionId == null) return null;
171
+ return { kind: 'rule_change', ruleVersionId, payload: { change: 'competitor_definition', include: entry.label } };
172
+ }
173
+ const itemKind = entry.question === 'rival' ? 'competitor' : 'topic';
174
+ return { kind: 'add_item', ruleVersionId: null, payload: { item_kind: itemKind, name: entry.label } };
175
+ }
176
+
177
+ // teamRow(p) → one line of "this week's proposals from the team", as data the page escapes:
178
+ // verb, the name in bold, the tail, who filed it first, how many agree, and its status.
179
+ function teamRow(p) {
180
+ const pl = p.payload || {};
181
+ let row;
182
+ // "competitor", the card's own word for it (the mock's rows said "rival"; one word per screen).
183
+ if (p.kind === 'rule_change') row = { verb: 'Count', name: pl.include || '', tail: 'as a competitor' };
184
+ else if (pl.item_kind === 'competitor') row = { verb: 'Add', name: pl.name || '', tail: 'as a competitor' };
185
+ else row = { verb: 'Watch', name: pl.name || '', tail: '' };
186
+ return { id: Number(p.id), ...row, who: p.who || null, agree: Number(p.agree) || 0, status: p.status };
187
+ }
188
+
189
+ // ---- the flow ---------------------------------------------------------------
190
+
191
+ // issueWeeklyAsk — this week's questions, once. Returns how many rows it created (0 when the
192
+ // week's ask is already out). Chips are a snapshot of the project's data at issue time, so a
193
+ // card reads the same all week.
194
+ async function issueWeeklyAsk(store, { project, now = new Date() }) {
195
+ const weekOf = weekStart(now);
196
+ const existing = await store.asksForWeek(project, weekOf);
197
+ if (existing.length >= QUESTIONS.length) return { weekOf, created: 0 };
198
+ const chips = chipOptions(await store.chipSource(project));
199
+ const closesAt = addDays(weekOf, 7);
200
+ let created = 0;
201
+ for (const q of QUESTIONS) {
202
+ if (await store.insertAsk(project, { weekOf, questionKey: q.key, prompt: q.prompt, options: chips[q.key], closesAt })) created += 1;
203
+ }
204
+ return { weekOf, created };
205
+ }
206
+
207
+ function openAsks(asks, now) {
208
+ const t = new Date(now).getTime();
209
+ return asks.filter((a) => !a.closes_at || new Date(a.closes_at).getTime() > t);
210
+ }
211
+
212
+ // cardFor — the member's card for this week, or null (nothing to show: no ask, closed, or
213
+ // already answered). The answered check is per WEEK: one answer to either question is
214
+ // the week answered.
215
+ async function cardFor(store, { project, builderId, now = new Date() }) {
216
+ const weekOf = weekStart(now);
217
+ const asks = openAsks(await store.asksForWeek(project, weekOf), now);
218
+ if (!asks.length) return null;
219
+ if (await store.answeredWeek(project, weekOf, builderId)) return null;
220
+ const [streak, team] = await Promise.all([
221
+ store.streak(project, builderId, now),
222
+ store.teamProposals(project, weekOf),
223
+ ]);
224
+ const byKey = new Map(asks.map((a) => [a.question_key, a]));
225
+ return {
226
+ week_of: weekOf,
227
+ week_no: isoWeek(weekOf),
228
+ closes_at: asks[0].closes_at,
229
+ next_on: addDays(weekOf, 7).toISOString().slice(0, 10),
230
+ streak,
231
+ questions: QUESTIONS.filter((q) => byKey.has(q.key)).map((q) => ({
232
+ key: q.key,
233
+ prompt: byKey.get(q.key).prompt,
234
+ options: (byKey.get(q.key).options || []).map((o) => ({ label: String(o.label || ''), example: String(o.example || '') })),
235
+ })),
236
+ team: team.map(teamRow),
237
+ };
238
+ }
239
+
240
+ // answer — store a member's answer and pool it. `builderId` is ALWAYS the caller (the route
241
+ // passes req.builder.id); nothing in the body can name another member.
242
+ // Returns { status, body }.
243
+ async function answer(store, { project, builderId, body, now = new Date() }) {
244
+ const parsed = parseAnswer(body);
245
+ if (parsed.error) return { status: parsed.status, body: { error: { code: parsed.error, message: parsed.message } } };
246
+ const weekOf = weekStart(now);
247
+ const closed = { status: 409, body: { error: { code: 'ask_closed', message: "This week's ask has closed." } } };
248
+ if (body.week_of != null && String(body.week_of).slice(0, 10) !== weekOf) return closed;
249
+ const asks = openAsks(await store.asksForWeek(project, weekOf), now);
250
+ const byKey = new Map(asks.map((a) => [a.question_key, a]));
251
+ if (!byKey.size) return closed;
252
+
253
+ // A chip must be one the card offered; its stored label is the option's own, not the body's.
254
+ for (const e of parsed.entries) {
255
+ const ask = byKey.get(e.question);
256
+ if (!ask) return closed;
257
+ if (e.free) continue;
258
+ const opt = (ask.options || []).find((o) => normalise(o.label) === e.choice);
259
+ if (!opt) return { status: 400, body: { error: { code: 'unknown_choice', message: 'That chip is not on this card.' } } };
260
+ e.label = cleanText(String(opt.label)).slice(0, MAX_TEXT);
261
+ }
262
+
263
+ const ruleVersionId = await store.activeRuleVersionId(project);
264
+ const result = await store.tx(async (tx) => {
265
+ const out = { answers: 0, proposals: 0, pooled: 0 };
266
+ if (parsed.nothing) {
267
+ const first = byKey.get('rival') || asks[0];
268
+ if (await tx.insertAnswer({ project, askId: first.id, builderId, choice: NOTHING, freeText: null })) out.answers += 1;
269
+ return out;
270
+ }
271
+ for (const e of parsed.entries) {
272
+ const ask = byKey.get(e.question);
273
+ const answerId = await tx.insertAnswer({ project, askId: ask.id, builderId, choice: e.choice, freeText: e.free ? e.label : null });
274
+ if (!answerId) continue; // already tapped this one: a no-op
275
+ out.answers += 1;
276
+ const key = poolKey(e.question, e.choice);
277
+ await tx.lockPool(project, key);
278
+ if (await tx.findPooled(project, key)) { out.pooled += 1; continue; }
279
+ const spec = proposalFor(e, ruleVersionId);
280
+ if (!spec) continue;
281
+ await tx.insertProposal({
282
+ project,
283
+ kind: spec.kind,
284
+ ruleVersionId: spec.ruleVersionId,
285
+ answerId,
286
+ proposedBy: builderId,
287
+ payload: { ...spec.payload, pool_key: key, question: e.question, choice: e.choice },
288
+ reason: `From the weekly ask, week of ${weekOf}.`,
289
+ });
290
+ out.proposals += 1;
291
+ }
292
+ return out;
293
+ });
294
+ return { status: 200, body: { ok: true, week_of: weekOf, ...result } };
295
+ }
296
+
297
+ // ---- the Postgres store -----------------------------------------------------
298
+
299
+ const ASKS_SQL = `
300
+ SELECT id, question_key, prompt, options, closes_at
301
+ FROM market_watch_weekly_asks WHERE project = $1 AND week_of = $2::date
302
+ ORDER BY question_key`;
303
+ const INSERT_ASK_SQL = `
304
+ INSERT INTO market_watch_weekly_asks (project, week_of, question_key, prompt, options, closes_at)
305
+ VALUES ($1, $2::date, $3, $4, $5::jsonb, $6)
306
+ ON CONFLICT (project, week_of, question_key) DO NOTHING
307
+ RETURNING id`;
308
+ const CHIP_SOURCE_SQL = `
309
+ SELECT kind, name, category, status FROM market_watch_items
310
+ WHERE project = $1 AND status = 'active' ORDER BY id`;
311
+ const ANSWERED_SQL = `
312
+ SELECT EXISTS (SELECT 1 FROM market_watch_ask_answers x
313
+ JOIN market_watch_weekly_asks a ON a.id = x.ask_id
314
+ WHERE a.project = $1 AND a.week_of = $2::date AND x.builder_id = $3) AS answered`;
315
+ const ACTIVE_RULES_SQL = `SELECT id FROM market_watch_rule_versions WHERE project = $1 AND status = 'active' LIMIT 1`;
316
+ // The answer's ask must be the project's: the guard is in the statement, not only the caller.
317
+ const INSERT_ANSWER_SQL = `
318
+ INSERT INTO market_watch_ask_answers (ask_id, builder_id, choice, free_text)
319
+ SELECT a.id, $3, $4, $5 FROM market_watch_weekly_asks a WHERE a.id = $2 AND a.project = $1
320
+ ON CONFLICT (ask_id, builder_id, choice) DO NOTHING
321
+ RETURNING id`;
322
+ // A pool already holds a proposal unless the one it held was turned down or withdrawn.
323
+ // Served by market_watch_proposals_ask_pool (migration 003), not a scan.
324
+ const FIND_POOLED_SQL = `
325
+ SELECT id FROM market_watch_proposals
326
+ WHERE project = $1 AND origin = 'weekly_ask' AND payload->>'pool_key' = $2
327
+ AND status IN ('open', 'approved', 'applied')
328
+ LIMIT 1`;
329
+ const INSERT_PROPOSAL_SQL = `
330
+ INSERT INTO market_watch_proposals (project, kind, origin, rule_version_id, answer_id, payload, reason, proposed_by)
331
+ VALUES ($1, $2, 'weekly_ask', $3, $4, $5::jsonb, $6, $7)
332
+ RETURNING id`;
333
+ // This week's weekly-ask proposals with their derived agree count: the distinct members
334
+ // whose answers carry the proposal's question and choice. The count is NOT limited to this
335
+ // week on purpose: a pool outlives the week it was opened in, and a member who named the
336
+ // same rival last week still agrees. It is indexed both ways: the project's asks by
337
+ // UNIQUE (project, week_of, question_key), their answers by UNIQUE (ask_id, builder_id,
338
+ // choice) — plain column equalities, no computed string. At most five rows are counted.
339
+ const TEAM_SQL = `
340
+ SELECT p.id, p.kind, p.payload, p.status,
341
+ COALESCE(NULLIF(b.display_name, ''), b.github_login) AS who,
342
+ (SELECT count(DISTINCT x.builder_id)
343
+ FROM market_watch_weekly_asks a
344
+ JOIN market_watch_ask_answers x ON x.ask_id = a.id AND x.choice = p.payload->>'choice'
345
+ WHERE a.project = p.project AND a.question_key = p.payload->>'question')::int AS agree
346
+ FROM market_watch_proposals p
347
+ LEFT JOIN builders b ON b.id = p.proposed_by
348
+ WHERE p.project = $1 AND p.origin = 'weekly_ask' AND p.created_at >= $2::date
349
+ AND p.status IN ('open', 'approved', 'applied')
350
+ ORDER BY agree DESC, p.created_at DESC, p.id DESC
351
+ LIMIT $3`;
352
+
353
+ function queriesOn(q) {
354
+ return {
355
+ async insertAnswer({ project, askId, builderId, choice, freeText }) {
356
+ const { rows: [r] } = await q.query(INSERT_ANSWER_SQL, [project, askId, builderId, choice, freeText]);
357
+ return r ? Number(r.id) : null;
358
+ },
359
+ async lockPool(p, key) {
360
+ await q.query('SELECT pg_advisory_xact_lock(hashtext($1))', [`market-watch-pool:${p}:${key}`]);
361
+ },
362
+ async findPooled(p, key) {
363
+ const { rows: [r] } = await q.query(FIND_POOLED_SQL, [p, key]);
364
+ return r ? Number(r.id) : null;
365
+ },
366
+ async insertProposal(x) {
367
+ const { rows: [r] } = await q.query(INSERT_PROPOSAL_SQL, [x.project, x.kind, x.ruleVersionId, x.answerId, JSON.stringify(x.payload), x.reason, x.proposedBy]);
368
+ return Number(r.id);
369
+ },
370
+ };
371
+ }
372
+
373
+ // createAskStore(pool) — the SQL behind the flow. Every read and write takes the project,
374
+ // and an answer's insert proves its ask is that project's in the statement itself.
375
+ function createAskStore(pool) {
376
+ const db = require('./db');
377
+ return {
378
+ async asksForWeek(project, weekOf) {
379
+ const { rows } = await pool.query(ASKS_SQL, [project, weekOf]);
380
+ return rows.map((r) => ({ ...r, id: Number(r.id) }));
381
+ },
382
+ async insertAsk(project, a) {
383
+ const { rows } = await pool.query(INSERT_ASK_SQL, [project, a.weekOf, a.questionKey, a.prompt, JSON.stringify(a.options || []), a.closesAt]);
384
+ return rows.length > 0;
385
+ },
386
+ async chipSource(project) {
387
+ const { rows } = await pool.query(CHIP_SOURCE_SQL, [project]);
388
+ return rows;
389
+ },
390
+ async answeredWeek(project, weekOf, builderId) {
391
+ const { rows: [r] } = await pool.query(ANSWERED_SQL, [project, weekOf, builderId]);
392
+ return r.answered === true;
393
+ },
394
+ streak: (project, builderId, now) => db.askStreak(pool, { project, builderId, now }),
395
+ async teamProposals(project, weekOf, limit = 5) {
396
+ const { rows } = await pool.query(TEAM_SQL, [project, weekOf, limit]);
397
+ return rows;
398
+ },
399
+ async activeRuleVersionId(project) {
400
+ const { rows: [r] } = await pool.query(ACTIVE_RULES_SQL, [project]);
401
+ return r ? Number(r.id) : null;
402
+ },
403
+ async tx(fn) {
404
+ const client = await pool.connect();
405
+ try {
406
+ await client.query('BEGIN');
407
+ const out = await fn(queriesOn(client));
408
+ await client.query('COMMIT');
409
+ return out;
410
+ } catch (e) {
411
+ await client.query('ROLLBACK').catch(() => {});
412
+ throw e;
413
+ } finally {
414
+ client.release();
415
+ }
416
+ },
417
+ };
418
+ }
419
+
420
+ module.exports = {
421
+ // the flow
422
+ issueWeeklyAsk, cardFor, answer, createAskStore,
423
+ // pure
424
+ weekStart, isoWeek, cleanText, normalise, chipOptions, parseAnswer,
425
+ };
@@ -0,0 +1,21 @@
1
+ -- market_watch_003_ask_pool.sql - the weekly ask's pool lookup (task 1004940, goal 1000137).
2
+ --
3
+ -- Each answer to the weekly ask looks for the proposal its pool already holds
4
+ -- (modules/market-watch/ask.js FIND_POOLED_SQL: project + payload->>'pool_key', origin
5
+ -- 'weekly_ask'). Without this index that lookup scans every proposal the project has, once
6
+ -- per answer, inside the answer's transaction. The partial index covers exactly the rows the
7
+ -- lookup can match, whatever their status.
8
+ --
9
+ -- The agree count (TEAM_SQL) needs no new index: it walks the project's asks by
10
+ -- UNIQUE (project, week_of, question_key) and their answers by UNIQUE (ask_id, builder_id,
11
+ -- choice).
12
+ --
13
+ -- Additive and idempotent, like 001 and 002.
14
+
15
+ BEGIN;
16
+
17
+ CREATE INDEX IF NOT EXISTS market_watch_proposals_ask_pool
18
+ ON market_watch_proposals (project, (payload->>'pool_key'))
19
+ WHERE origin = 'weekly_ask';
20
+
21
+ COMMIT;
@@ -1,8 +1,8 @@
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) and its weekly run (an hourly poller that researches each tracked item on the project's own Anthropic key, under the monthly cap) are in; the pages and weekly ask are not yet, so it mounts no routes. 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.2.0",
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) and its weekly run (an hourly poller that researches each tracked item on the project's own Anthropic key, under the monthly cap) are in, and so is the weekly ask card in the hall's attention inbox (task 1004940); the pages are not yet. 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.3.0",
6
6
  "coreVersion": "^1.19.0",
7
7
  "category": {
8
8
  "parent": "knowledge"
@@ -13,9 +13,16 @@
13
13
  },
14
14
  "contributes": {
15
15
  "migrations": true,
16
+ "routes": [
17
+ "ask"
18
+ ],
16
19
  "pollers": [
17
20
  "hub-seed",
18
- "weekly-run"
21
+ "weekly-run",
22
+ "weekly-ask"
23
+ ],
24
+ "uiSections": [
25
+ "weekly-ask"
19
26
  ]
20
27
  },
21
28
  "spend": {
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ // modules/market-watch/pollers/weekly-ask.js — put out the week's ask card (task 1004940,
4
+ // the ADR 0394 §2 poller shape).
5
+ //
6
+ // The same shape as weekly-run.js, not an import of it: one unref'd HOURLY tick, the first a
7
+ // few minutes after boot plus jitter, kill-switched by ROUTINE_TIMERS_DISABLED=1, and it never
8
+ // throws into the server.
9
+ //
10
+ // BY THE WALL CLOCK, IDEMPOTENT PER PROJECT PER WEEK. The ask belongs to the calendar week
11
+ // (Monday 00:00 UTC, ask.js weekStart), not to the boot: the first tick of a new week creates
12
+ // that week's two question rows, and every later tick that week finds them and does nothing.
13
+ // The UNIQUE (project, week_of, question_key) constraint is what makes it once even with two
14
+ // processes on one database. A deploy restarts this timer and changes nothing.
15
+ //
16
+ // It sends nothing. A member sees the card in the hall's "Needs your attention" card while
17
+ // the week is open and they have not answered; when the week closes it is gone. No reminder,
18
+ // no second ask (the owner's "never nag").
19
+
20
+ const api = require('../../../src/module-api');
21
+ const ask = require('../ask');
22
+ const { projectKey } = require('../seed');
23
+
24
+ const log = api.logger('market-watch');
25
+
26
+ const TICK_MS = Number(process.env.MARKET_WATCH_ASK_TICK_MS) || 60 * 60_000;
27
+ // After hub-seed's 2 minutes, so the hub's first card is drawn from its seeded items.
28
+ const FIRST_MS = Number(process.env.MARKET_WATCH_ASK_FIRST_MS) || 5 * 60_000;
29
+ const JITTER_MS = 60_000;
30
+
31
+ function defaultProject() {
32
+ try { return projectKey(api.branding().domains.publicOrigin); } catch { return null; }
33
+ }
34
+
35
+ let running = false;
36
+ let warnedUnreachable = false;
37
+
38
+ // tick(deps) — issue this week's ask if it is not out yet. NEVER throws.
39
+ async function tick(deps = {}) {
40
+ if (running) return { skipped: 'busy' };
41
+ running = true;
42
+ try {
43
+ const project = deps.project !== undefined ? deps.project : defaultProject();
44
+ if (!project) return { skipped: 'no_project_origin' };
45
+ const store = deps.store || ask.createAskStore(deps.pool || api.pool);
46
+ const r = await ask.issueWeeklyAsk(store, { project, now: deps.now ? deps.now() : new Date() });
47
+ if (r.created) log.info(`[weekly-ask] ${project}: week of ${r.weekOf} asked (${r.created} questions)`);
48
+ return r;
49
+ } catch (e) {
50
+ const msg = (e && e.message) || String(e);
51
+ if (/ECONNREFUSED|ENOTFOUND|does not exist|connect/i.test(msg)) {
52
+ if (!warnedUnreachable) { warnedUnreachable = true; log.info(`[weekly-ask] skipped — database unreachable (${msg})`); }
53
+ return { skipped: 'db_unreachable' };
54
+ }
55
+ log.error(`[weekly-ask] tick failed: ${msg}`);
56
+ if (typeof deps.reportError === 'function') deps.reportError();
57
+ return { skipped: 'error', error: msg };
58
+ } finally {
59
+ running = false;
60
+ }
61
+ }
62
+
63
+ let firstTimer = null;
64
+ let interval = null;
65
+
66
+ // Only reportError is taken from the loader's deps, so a loader dep is never read as a seam.
67
+ function start({ reportError } = {}) {
68
+ if (process.env.ROUTINE_TIMERS_DISABLED === '1') {
69
+ log.info('[weekly-ask] disabled (ROUTINE_TIMERS_DISABLED=1)');
70
+ return;
71
+ }
72
+ if (firstTimer || interval) return;
73
+ const fire = () => tick({ reportError });
74
+ firstTimer = setTimeout(() => {
75
+ firstTimer = null;
76
+ fire();
77
+ interval = setInterval(fire, TICK_MS);
78
+ if (interval.unref) interval.unref();
79
+ }, FIRST_MS + Math.floor(Math.random() * JITTER_MS));
80
+ if (firstTimer.unref) firstTimer.unref();
81
+ }
82
+
83
+ function stop() {
84
+ if (firstTimer) clearTimeout(firstTimer);
85
+ if (interval) clearInterval(interval);
86
+ firstTimer = interval = null;
87
+ }
88
+
89
+ module.exports = { start, stop, tick, TICK_MS };