@bongos/core 1.21.173 → 1.21.175

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 (38) hide show
  1. package/.bongos-core.json +65 -40
  2. package/docs/copy-inventory.md +56 -62
  3. package/docs/copy-registry.json +91 -156
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/page-inventory.json +5 -0
  6. package/docs/page-readings.json +64 -61
  7. package/modules/copy-desk/routes/copy-desk.js +4 -4
  8. package/modules/hall-ui/public/blockers.html +2 -0
  9. package/modules/hall-ui/public/blockers.js +13 -0
  10. package/modules/hall-ui/public/board-room.css +5 -0
  11. package/modules/hall-ui/public/board-room.html +1 -0
  12. package/modules/hall-ui/public/board-room.js +2 -2
  13. package/modules/hall-ui/public/city-decide.js +36 -2
  14. package/modules/hall-ui/public/city.css +8 -2
  15. package/modules/hall-ui/public/city.html +5 -5
  16. package/modules/hall-ui/public/goal-inbox.js +69 -86
  17. package/modules/hall-ui/public/hall-ask.css +57 -0
  18. package/modules/hall-ui/public/hall-ask.js +50 -0
  19. package/modules/hall-ui/public/index.html +3 -0
  20. package/modules/hall-ui/records/hall-ask-card.md +24 -0
  21. package/modules/lifecycle/artist-review-goal.js +103 -0
  22. package/modules/lifecycle/db-tasks.js +17 -1
  23. package/modules/lifecycle/db-versions.js +5 -1
  24. package/modules/lifecycle/page-tweak-claim.js +3 -3
  25. package/modules/lifecycle/page-tweak-write.js +12 -4
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/release-notes.json +16 -0
  29. package/src/module-api.js +1 -1
  30. package/tests/artist_review_goal.mjs +119 -0
  31. package/tests/blockers_hall.mjs +3 -1
  32. package/tests/copy_desk_page_claim.mjs +6 -1
  33. package/tests/copy_desk_page_submit.mjs +1 -1
  34. package/tests/fixtures/page-ledger.mjs +13 -0
  35. package/tests/hall_city_rewording.mjs +11 -0
  36. package/tests/home_attention_cards.mjs +2 -0
  37. package/tests/rot_inbox.mjs +10 -7
  38. package/tests/task_goal_required.mjs +12 -2
@@ -0,0 +1,119 @@
1
+ // tests/artist_review_goal.mjs
2
+ //
3
+ // Every version's ARTIST REVIEW goal (task 1004819): the find-or-create behind
4
+ // createTask's `artistReview` switch and createVersion. DB-free: a recording fake
5
+ // client stands in for Postgres, and the statement order is the contract — the
6
+ // version gate before anything else, the advisory lock before the read, and the
7
+ // two standing criteria written only when the goal is new.
8
+ //
9
+ // Run: node tests/artist_review_goal.mjs
10
+
11
+ import { strict as assert } from 'node:assert';
12
+ import { createRequire } from 'node:module';
13
+ import { makeRunner } from './helpers.mjs';
14
+
15
+ process.env.NODE_ENV = 'test';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const { ensureArtistReviewGoal, artistReviewGoalTitle, ARTIST_REVIEW_CRITERIA } = require('../modules/lifecycle/artist-review-goal.js');
19
+ const { createTask } = require('../modules/lifecycle/db.js');
20
+
21
+ const { test, summary } = makeRunner();
22
+ const norm = (s) => String(s).replace(/\s+/g, ' ').trim();
23
+
24
+ // One version, at most one goal: the fake answers like the tables would.
25
+ function fakeClient({ status = 'building', goal = null } = {}) {
26
+ const queries = [];
27
+ const state = { goal, criteria: [], tasks: [] };
28
+ const client = {
29
+ queries, state,
30
+ async query(sql, params = []) {
31
+ const q = norm(sql);
32
+ queries.push({ sql: q, params });
33
+ if (/^(BEGIN|COMMIT|ROLLBACK)$/.test(q)) return { rows: [] };
34
+ if (/^SELECT status FROM versions WHERE id = \$1/.test(q)) return { rows: status ? [{ status }] : [] };
35
+ if (/pg_advisory_xact_lock/.test(q)) return { rows: [] };
36
+ if (/^SELECT id, version_id, title FROM goals WHERE version_id = \$1 AND title = \$2/.test(q)) {
37
+ return { rows: state.goal && state.goal.title === params[1] ? [state.goal] : [] };
38
+ }
39
+ if (/^INSERT INTO goals/.test(q)) { state.goal = { id: 91, version_id: params[0], title: params[1] }; return { rows: [state.goal] }; }
40
+ if (/^INSERT INTO done_when_criteria/.test(q)) { state.criteria.push({ id: params[1], goal: params[3] }); return { rows: [] }; }
41
+ if (/^SELECT scope_modules FROM goals WHERE id = \$1/.test(q)) return { rows: [{ scope_modules: [] }] };
42
+ if (/^INSERT INTO tasks/.test(q)) { const t = { id: 1, goal_id: params[22] }; state.tasks.push(t); return { rows: [t] }; }
43
+ return { rows: [] };
44
+ },
45
+ release() {},
46
+ };
47
+ return client;
48
+ }
49
+
50
+ await test('the title is the version plus " — artist review" (em dash)', () => {
51
+ assert.equal(artistReviewGoalTitle('BONGOS-V3'), 'BONGOS-V3 — artist review');
52
+ assert.deepEqual(ARTIST_REVIEW_CRITERIA.map((c) => c.criterion_id), ['artist-every-page-reviewed', 'artist-pages-simple-to-use']);
53
+ });
54
+
55
+ await test('a new goal is made with its two criteria, after the version gate and the lock', async () => {
56
+ const c = fakeClient();
57
+ const g = await ensureArtistReviewGoal('BONGOS-V3', { client: c });
58
+ assert.equal(g.title, 'BONGOS-V3 — artist review');
59
+ const order = c.queries.map((q) => q.sql.split(' ').slice(0, 3).join(' '));
60
+ const at = (re) => c.queries.findIndex((q) => re.test(q.sql));
61
+ assert.ok(at(/FROM versions/) < at(/pg_advisory_xact_lock/), 'the version is checked first');
62
+ assert.ok(at(/pg_advisory_xact_lock/) < at(/FROM goals WHERE version_id/), 'the lock is taken before the read');
63
+ assert.equal(c.queries.find((q) => /pg_advisory_xact_lock/.test(q.sql)).params[0], 'BONGOS-V3 — artist review', 'locked on the title');
64
+ assert.deepEqual(c.state.criteria, [{ id: 'artist-every-page-reviewed', goal: 91 }, { id: 'artist-pages-simple-to-use', goal: 91 }]);
65
+ assert.ok(/ON CONFLICT \(version_id, criterion_id\) DO NOTHING/.test(c.queries.find((q) => /INSERT INTO done_when_criteria/.test(q.sql)).sql),
66
+ 'a criterion already on the version is left alone', order);
67
+ });
68
+
69
+ await test('an existing goal is reused, never duplicated, and still carries the two criteria', async () => {
70
+ // A goal of this title made by hand before the first studio write is found,
71
+ // and its criteria are seeded (ON CONFLICT DO NOTHING keeps any already there).
72
+ const c = fakeClient({ goal: { id: 1000134, version_id: 'BONGOS-V2', title: 'BONGOS-V2 — artist review' } });
73
+ const g = await ensureArtistReviewGoal('BONGOS-V2', { client: c });
74
+ assert.equal(g.id, 1000134);
75
+ assert.equal(c.queries.some((q) => /^INSERT INTO goals/.test(q.sql)), false, 'no second goal');
76
+ assert.deepEqual(c.state.criteria, [{ id: 'artist-every-page-reviewed', goal: 1000134 }, { id: 'artist-pages-simple-to-use', goal: 1000134 }]);
77
+ });
78
+
79
+ await test('a closed or missing version gets no goal, and nothing is locked or written', async () => {
80
+ for (const status of ['shipped', null]) {
81
+ const c = fakeClient({ status });
82
+ assert.equal(await ensureArtistReviewGoal('V0', { client: c }), null);
83
+ assert.equal(c.queries.length, 1, 'only the version read ran');
84
+ }
85
+ const planning = fakeClient({ status: 'planning' });
86
+ assert.ok(await ensureArtistReviewGoal('V4', { client: planning }), 'a planning version takes one, so a new version is born with it');
87
+ });
88
+
89
+ await test('with no caller transaction it opens its own, so the lock means something', async () => {
90
+ const c = fakeClient();
91
+ const pool = { connect: async () => c, query: (...a) => c.query(...a) };
92
+ await ensureArtistReviewGoal('BONGOS-V3', { pool });
93
+ const sqls = c.queries.map((q) => q.sql);
94
+ assert.equal(sqls[0], 'BEGIN');
95
+ assert.equal(sqls.at(-1), 'COMMIT');
96
+ });
97
+
98
+ await test('createTask with artistReview lands in the goal; without one the catch-all still answers', async () => {
99
+ const c = fakeClient();
100
+ await createTask({ versionId: 'BONGOS-V3', title: 'Tweak: Home (builders:home)', discipline: 'artist', artistReview: true }, { client: c });
101
+ assert.equal(c.state.tasks[0].goal_id, 91, 'the studio task names the artist review goal');
102
+ await assert.rejects(
103
+ () => createTask({ versionId: 'BONGOS-V3', title: 'plain', discipline: 'engineer' }, { client: fakeClient() }),
104
+ /goalId is required/, 'an ordinary goal-less create still throws');
105
+ });
106
+
107
+ await test('a create with no caller transaction (a copy proposal) puts goal and task in ONE transaction', async () => {
108
+ const c = fakeClient();
109
+ const pool = { connect: async () => c, query: () => { throw new Error('nothing may run outside the transaction'); } };
110
+ await createTask({ versionId: 'BONGOS-V3', title: 'Reword the Home heading', discipline: 'artist', artistReview: true }, { pool });
111
+ const sqls = c.queries.map((q) => q.sql);
112
+ const begins = sqls.filter((q) => q === 'BEGIN').length;
113
+ assert.equal(begins, 1, 'exactly one transaction');
114
+ assert.ok(sqls.indexOf('BEGIN') < sqls.findIndex((q) => /^INSERT INTO goals/.test(q)), 'the goal is made inside it');
115
+ assert.ok(sqls.findIndex((q) => /^INSERT INTO tasks/.test(q)) < sqls.lastIndexOf('COMMIT'), 'and the task too, before the one COMMIT');
116
+ assert.equal(c.state.tasks[0].goal_id, 91);
117
+ });
118
+
119
+ summary();
@@ -54,7 +54,9 @@ test('goal-inbox.js renders blocker rows through the SHARED lib, not a re-implem
54
54
  });
55
55
 
56
56
  test('the blocker row carries .ginbox__item, so the card\'s count-pill and visibility include it', () => {
57
- assert.match(GOAL_INBOX_SRC, /function blockerRowHtml[\s\S]*?class="ginbox__item"/);
57
+ // task 1004820: the row is the city's ask card, drawn by OTBAsk with the item class
58
+ assert.match(GOAL_INBOX_SRC, /function blockerRowHtml[\s\S]*?OTBAsk\.html\(\{[\s\S]*?cls: 'ginbox__item'/);
59
+ assert.match(GOAL_INBOX_SRC, /function blockerRowHtml[\s\S]*?label: 'Resolve it'/, 'the home blocker row is actionable');
58
60
  });
59
61
 
60
62
  test('the mount() boot sequence actually calls loadBlockers(), in its place in the order', () => {
@@ -86,8 +86,13 @@ try {
86
86
  assert.equal(db.tasks.length, 1);
87
87
  assert.deepEqual(
88
88
  [t.source, t.source_ref, t.title, t.kind, t.discipline, t.credits_reward, t.version_id, t.goal_id, t.status],
89
- ['page-tweak', 'page-tweak/builders:studio/r1', 'Tweak: Studio (builders:studio)', 'feature', 'artist', 30, 'V9', '77', 'active']
89
+ ['page-tweak', 'page-tweak/builders:studio/r1', 'Tweak: Studio (builders:studio)', 'feature', 'artist', 30, 'V9', '88', 'active']
90
90
  );
91
+ // task 1004819: the round lands in the version's artist review goal, made on
92
+ // first use with its two standing criteria — never the catch-all.
93
+ assert.equal(db.artistGoal.title, 'V9 — artist review');
94
+ assert.deepEqual(db.criteria.map((x) => [x.criterion_id, x.goal_id]),
95
+ [['artist-every-page-reviewed', '88'], ['artist-pages-simple-to-use', '88']]);
91
96
  assert.deepEqual(t.touches, F.STUDIO_FILES, 'touches are the inventory page files');
92
97
  const c = db.claims[0];
93
98
  assert.deepEqual([c.builder_id, c.worktree_name, c.creator_session_id], [ALICE, null, null], 'a web claim: no worktree, no session');
@@ -208,7 +208,7 @@ try {
208
208
  `page-tweak-unplaced/${studioTask().id}/L0005`,
209
209
  ]);
210
210
  for (const t of eng) {
211
- assert.deepEqual([t.status, t.discipline, t.kind, t.version_id, t.goal_id, t.created_by], ['backlog', 'engineer', 'feature', 'V9', '77', ALICE]);
211
+ assert.deepEqual([t.status, t.discipline, t.kind, t.version_id, t.goal_id, t.created_by], ['backlog', 'engineer', 'feature', 'V9', '88', ALICE], 'an unplaced line lands in the artist review goal (task 1004819)');
212
212
  assert.match(t.description, /builders:studio/);
213
213
  }
214
214
  const quiet = eng.find((t) => t.source_ref.endsWith('/L0005'));
@@ -36,6 +36,7 @@ export function installPageLedger(api, { builders }) {
36
36
  // first commits or rolls back.
37
37
  const locks = new Map();
38
38
  async function lock(key, owner) {
39
+ if (locks.has(key) && locks.get(key).owner === owner) return; // re-entrant, as Postgres is for one session
39
40
  while (locks.has(key)) await locks.get(key).done;
40
41
  let release;
41
42
  const done = new Promise((r) => { release = r; });
@@ -109,6 +110,18 @@ export function installPageLedger(api, { builders }) {
109
110
  if (/FROM goals g JOIN versions v ON v\.id = g\.version_id WHERE g\.version_id = \$1 AND g\.title = \$2/.test(q)) {
110
111
  return { rows: [{ id: '77', version_status: 'building' }] };
111
112
  }
113
+ // task 1004819: studio work lands in the version's artist review goal, found
114
+ // (or made, with its two criteria) on the same transaction client.
115
+ if (/^SELECT status FROM versions WHERE id = \$1/.test(q)) return { rows: [{ status: 'building' }] };
116
+ if (/^SELECT id, version_id, title FROM goals WHERE version_id = \$1 AND title = \$2/.test(q)) {
117
+ assert.ok(owner, 'the artist review goal must be found on the transaction client');
118
+ return { rows: db.artistGoal ? [{ ...db.artistGoal }] : [] };
119
+ }
120
+ if (/^INSERT INTO goals \(version_id, title, description, scope_modules, status, created_by, sort_order\)/.test(q)) {
121
+ db.artistGoal = { id: '88', version_id: p[0], title: p[1] };
122
+ return { rows: [{ ...db.artistGoal }] };
123
+ }
124
+ if (/^INSERT INTO done_when_criteria/.test(q)) { (db.criteria = db.criteria || []).push({ version_id: p[0], criterion_id: p[1], goal_id: p[3] }); return { rows: [] }; }
112
125
  if (/^SELECT scope_modules FROM goals WHERE id = \$1/.test(q)) return { rows: [{ scope_modules: [] }] };
113
126
  if (/^INSERT INTO tasks \(version_id, title, description, status, touches,/.test(q)) {
114
127
  assert.ok(owner, 'the round must be created inside the claim transaction');
@@ -49,3 +49,14 @@ test('the "?" asks before it sends: the send lives behind a confirm button', ()
49
49
  assert.match(fn, /Send this to Claude to rewrite\?/);
50
50
  assert.match(fn, /Nothing is decided or changed/);
51
51
  });
52
+
53
+ test('a card never cuts what it says: the whole text is one tap away, and only when there is more', () => {
54
+ const G = boot();
55
+ const m = { id: 'override_requests:1', title: 'Override: a long title', detail: 'The whole story, word for word.' };
56
+ assert.equal(G.fullText(m, { question: 'Release it?' }), 'Override: a long title\n\nThe whole story, word for word.');
57
+ assert.equal(G.fullText(m, { question: 'Override: a long title' }), 'The whole story, word for word.', 'the question is not repeated');
58
+ assert.equal(G.fullText({ id: 'x:1', title: 'Same' }, { question: 'Same' }), '', 'nothing more to show, no opener');
59
+ assert.equal(G.fullText(null, { question: 'Q?' }), '');
60
+ const src = fs.readFileSync(path.join(PUB, 'city-decide.js'), 'utf8');
61
+ assert.match(src, /str\(o\.input && o\.input\.label\) \|\| str\(o\.label\)/, 'an answer uses its full words, not the desk chip cut to fit');
62
+ });
@@ -16,6 +16,7 @@ const require = createRequire(import.meta.url);
16
16
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
17
  const lib = require(path.join(ROOT, 'modules', 'ui-design', 'kit', 'lib.js'));
18
18
  const GOAL_INBOX = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'goal-inbox.js'), 'utf8');
19
+ const HALL_ASK = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'hall-ask.js'), 'utf8');
19
20
 
20
21
  let browser = null;
21
22
  try { browser = await lib.launchBrowser(); } catch (e) { if (!(e instanceof lib.NoBrowserError)) throw e; }
@@ -48,6 +49,7 @@ async function bootInbox() {
48
49
  const page = await ctx.newPage();
49
50
  await page.setContent(PAGE);
50
51
  await page.addScriptTag({ content: STUBS });
52
+ await page.addScriptTag({ content: HALL_ASK }); // task 1004820: the rows are OTBAsk cards
51
53
  await page.addScriptTag({ content: GOAL_INBOX });
52
54
  await page.evaluate(() => window.__widget.mount(document.getElementById('attention-scroll')));
53
55
  await page.waitForFunction(() => document.querySelectorAll('.attn-card').length === 2);
@@ -593,14 +593,14 @@ test('the slice lands in the EXISTING attention card — never a new Home card',
593
593
  });
594
594
 
595
595
  test('rot rows carry .ginbox__item, so the card\'s count-pill and visibility include them', () => {
596
- assert.match(GOAL_INBOX_SRC, /function rotRowHtml[\s\S]*?class="ginbox__item"/,
596
+ assert.match(GOAL_INBOX_SRC, /function rotRowHtml[\s\S]*?cls: 'ginbox__item'/,
597
597
  'a rot row that does not carry the shared item class would be invisible to OTBAttention.refresh()');
598
598
  });
599
599
 
600
600
  test('the client renders SERVER-declared verbs rather than a hardcoded button list', () => {
601
601
  assert.match(GOAL_INBOX_SRC, /\(Array\.isArray\(r\.verbs\) \? r\.verbs : \[\]\)\.map/,
602
602
  'the buttons must come from the row\'s verbs array — a hardcoded list is a second copy of the tier→verb mapping');
603
- assert.match(GOAL_INBOX_SRC, /data-rot-path=/, 'each button carries the server-declared path');
603
+ assert.match(GOAL_INBOX_SRC, /'data-rot-path':/, 'each button carries the server-declared path');
604
604
  });
605
605
 
606
606
  test('row builders emit the CSS classes style.css actually defines, not ginbox__main/ginbox__acts (task 1003284)', () => {
@@ -610,10 +610,10 @@ test('row builders emit the CSS classes style.css actually defines, not ginbox__
610
610
  '.ginbox__main is undefined in style.css — with it the row title and sub-line run together inline instead of stacking');
611
611
  assert.doesNotMatch(GOAL_INBOX_SRC, /ginbox__acts\b/,
612
612
  '.ginbox__acts is undefined in style.css — with it the action button loses its flex sizing');
613
- assert.match(GOAL_INBOX_SRC, /function nodRowHtml[\s\S]*?class="ginbox__body"[\s\S]*?class="ginbox__actions"/,
614
- 'nodRowHtml must emit the defined ginbox__body/ginbox__actions classes');
615
- assert.match(GOAL_INBOX_SRC, /function rotRowHtml[\s\S]*?class="ginbox__body"[\s\S]*?class="ginbox__actions"/,
616
- 'rotRowHtml must emit the defined ginbox__body/ginbox__actions classes');
613
+ // task 1004820: the nod and rot rows are the city's ask card, whose classes hall-ask.css defines
614
+ for (const fn of ['nodRowHtml', 'rotRowHtml']) {
615
+ assert.match(GOAL_INBOX_SRC, new RegExp(`function ${fn}[\\s\\S]*?OTBAsk\\.html\\(`), `${fn} draws the ask card`);
616
+ }
617
617
  });
618
618
 
619
619
  test('the nod, removal and rot lists use the defined .ginbox list class, not ginbox__list (task 1003982)', () => {
@@ -635,6 +635,9 @@ test('rotRowHtml escapes the row href like every sibling field (task 1003631)',
635
635
  const start = GOAL_INBOX_SRC.indexOf('function rotRowHtml');
636
636
  const body = GOAL_INBOX_SRC.slice(start, GOAL_INBOX_SRC.indexOf('\n }\n', start));
637
637
  assert.ok(start > 0, 'rotRowHtml exists');
638
- assert.match(body, /href="\$\{escapeHtml\(href\)\}"/, 'href goes through escapeHtml');
638
+ // task 1004820: the href is handed to OTBAsk, which escapes every attribute
639
+ assert.match(body, /qHref: href/, 'href is handed to OTBAsk');
640
+ const ask = require('../modules/hall-ui/public/hall-ask.js');
641
+ assert.match(ask.html({ q: 'x', qHref: '/t?a="<b>' }), /href="\/t\?a=&quot;&lt;b&gt;"/, 'OTBAsk escapes the href');
639
642
  assert.doesNotMatch(body, /href="\$\{href\}"/, 'no raw href interpolation');
640
643
  });
@@ -113,12 +113,11 @@ test('POST /tasks does NOT pass the escape hatch — this door always has a goal
113
113
 
114
114
  const OPT_INS = [
115
115
  ['modules/onboarding/newcomer-restock.js', 'the beginner queue, stamped from templates'],
116
- ['modules/copy-desk/routes/copy-desk.js', 'copy-change proposals'],
117
116
  ['modules/lifecycle/kickoff-seed.js', "the founder's first board, on an instance with no goals at all"],
118
117
  ['modules/lifecycle/cascade.js', 'a follow-up off a parent task that is itself goal-less'],
119
118
  ];
120
119
 
121
- test('exactly four vectors opt in, and each says why', () => {
120
+ test('exactly three vectors opt in, and each says why', () => {
122
121
  for (const [file, why] of OPT_INS) {
123
122
  const body = src(file);
124
123
  assert.match(body, /allowCatchAll/, `${file} should opt in (${why})`);
@@ -127,6 +126,17 @@ test('exactly four vectors opt in, and each says why', () => {
127
126
  }
128
127
  });
129
128
 
129
+ // task 1004819: studio work names its home instead of the catch-all. Each of the
130
+ // three studio create vectors passes artistReview, which lands it in the
131
+ // version's artist review goal; none of them opts into the catch-all any more.
132
+ test('the three studio vectors land in the artist review goal, not the catch-all', () => {
133
+ for (const file of ['modules/copy-desk/routes/copy-desk.js', 'modules/lifecycle/page-tweak-claim.js', 'modules/lifecycle/page-tweak-write.js']) {
134
+ const body = src(file);
135
+ assert.match(body, /artistReview: true/, `${file} should name the artist review goal`);
136
+ assert.equal(/allowCatchAll/.test(body), false, `${file} must not opt into the catch-all`);
137
+ }
138
+ });
139
+
130
140
  test('cascade opts in ONLY when inheritance actually failed', () => {
131
141
  // The others opt in unconditionally because they have no goal to name at all.
132
142
  // A cascade normally inherits its parent's goal and must keep doing so — the