@bongos/core 1.21.163 → 1.21.164

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 (35) hide show
  1. package/.bongos-core.json +54 -39
  2. package/.gitattributes +6 -0
  3. package/docs/copy-inventory.md +99 -99
  4. package/docs/copy-registry.json +149 -139
  5. package/docs/module-api-changelog.md +2 -0
  6. package/docs/page-readings.json +141 -306
  7. package/modules/copy-desk/page-status.js +3 -0
  8. package/modules/copy-desk/pages.js +5 -0
  9. package/modules/hall-ui/public/approval-queue-lib.js +4 -0
  10. package/modules/hall-ui/public/approval-queue.css +2 -0
  11. package/modules/hall-ui/public/approval-queue.js +1 -0
  12. package/modules/hall-ui/public/builders.js +6 -4
  13. package/modules/hall-ui/public/goal-inbox.js +100 -7
  14. package/modules/hall-ui/public/goals.js +2 -4
  15. package/modules/hall-ui/public/hall-render.js +39 -9
  16. package/modules/hall-ui/public/hall-widgets.js +20 -10
  17. package/modules/hall-ui/public/index.html +7 -0
  18. package/modules/hall-ui/public/style.css +95 -0
  19. package/modules/hall-ui/public/task-detail.js +8 -0
  20. package/modules/hall-ui/records/home-reshuffle.md +31 -0
  21. package/package-lock.json +2 -2
  22. package/package.json +1 -1
  23. package/release-notes.json +14 -0
  24. package/scripts/gds/copy-apply.js +65 -2
  25. package/scripts/gds/git-merge-regen.js +14 -0
  26. package/scripts/gds/page-reader.js +46 -0
  27. package/src/module-api.js +1 -1
  28. package/tests/conflict_resolve.mjs +6 -0
  29. package/tests/hall_approval_queue.mjs +16 -0
  30. package/tests/hall_board_world.mjs +7 -6
  31. package/tests/home_attention_cards.mjs +119 -0
  32. package/tests/home_bounded_reads.mjs +1 -1
  33. package/tests/promote_goal_id.mjs +8 -0
  34. package/tests/tweak_batch_apply.mjs +40 -0
  35. package/tests/tweak_land_heal.mjs +139 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.163",
3
+ "version": "1.21.164",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -10020,5 +10020,19 @@
10020
10020
  "id": "1004773",
10021
10021
  "text": "The Private option on the account page now opens with the artist line \"you are a ninja.\", and still tells people Private hides their page from everyone, them included."
10022
10022
  }
10023
+ ],
10024
+ "1.21.164": [
10025
+ {
10026
+ "id": "1004772",
10027
+ "text": "Promoting an idea from the home page's Triage button no longer fails with \"discipline was not supplied\". The Promote form now has a Craft choice (Engineer by default), so every promoted idea becomes a task."
10028
+ },
10029
+ {
10030
+ "id": "1004775",
10031
+ "text": "Approved page tweaks now land on their own. When two rounds were applied from the same main, the second used to hit a conflict in the page readings file and wait for a person; that file now merges page by page, so the second r"
10032
+ },
10033
+ {
10034
+ "id": "1004774",
10035
+ "text": "Home got the owner's reshuffle: a slimmer banner with the version's progress bar under it; your picture and rank at the top right, opening on hover into your standing card with a View full profile link; every Needs-attention s"
10036
+ }
10023
10037
  ]
10024
10038
  }
@@ -458,17 +458,73 @@ function applyBatchToSources({ batch, registry, readSource }) {
458
458
  return { applied: applied.sort(byOrder).map(({ order, ...rest }) => rest), refused, sources };
459
459
  }
460
460
 
461
+ // findPinningTests({ applied, listTests, readFile }) -> [{ key, tests: [rel…] }]
462
+ //
463
+ // Task 1004775. A test that asserts a line's OLD words verbatim goes red the
464
+ // moment the round lands, which used to surface only in CI, after the artist had
465
+ // approved, and needed a person. So at APPLY time every applied line's old text is
466
+ // searched for in the test files (tests/ and every module's own tests folder), and each test that
467
+ // still carries it is named in the applied block and shown in the Approval queue.
468
+ // Nothing is edited: a test may be guarding what the words MEAN, and only a person
469
+ // can tell that apart from a test that pins the wording alone.
470
+ //
471
+ // The needle is the line's old text; where it has {…} holes, its longest literal
472
+ // run between them, and a needle under MIN_PIN_NEEDLE characters is skipped (a
473
+ // two-word label matches half the suite and would name nothing useful).
474
+ const MIN_PIN_NEEDLE = 12;
475
+ const PIN_TEST_RE = /\.(mjs|cjs|js)$/;
476
+
477
+ function pinNeedle(text) {
478
+ const runs = String(text == null ? '' : text).split('{…}').map((s) => s.trim());
479
+ const longest = runs.reduce((a, b) => (b.length > a.length ? b : a), '');
480
+ return longest.length >= MIN_PIN_NEEDLE ? longest : null;
481
+ }
482
+
483
+ function listTestFiles({ root = REPO_ROOT, fsImpl = fs } = {}) {
484
+ const out = [];
485
+ const walk = (rel) => {
486
+ let ents;
487
+ try { ents = fsImpl.readdirSync(path.join(root, rel), { withFileTypes: true }); } catch { return; }
488
+ for (const e of ents) {
489
+ const r = `${rel}/${e.name}`;
490
+ if (e.isDirectory()) { if (e.name !== 'node_modules' && e.name !== 'fixtures') walk(r); } else if (PIN_TEST_RE.test(e.name)) out.push(r);
491
+ }
492
+ };
493
+ walk('tests');
494
+ let mods = [];
495
+ try { mods = fsImpl.readdirSync(path.join(root, 'modules'), { withFileTypes: true }); } catch { mods = []; }
496
+ for (const m of mods) if (m.isDirectory()) walk(`modules/${m.name}/tests`);
497
+ return out.sort();
498
+ }
499
+
500
+ function findPinningTests({ applied, listTests = listTestFiles, readFile = (rel) => fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8') }) {
501
+ const wants = (applied || []).map((a) => ({ key: a.key, needle: pinNeedle(a.before) })).filter((w) => w.needle);
502
+ if (!wants.length) return [];
503
+ const hits = new Map();
504
+ for (const rel of listTests()) {
505
+ let src;
506
+ try { src = readFile(rel); } catch { continue; }
507
+ for (const w of wants) {
508
+ if (!src.includes(w.needle)) continue;
509
+ if (!hits.has(w.key)) hits.set(w.key, []);
510
+ hits.get(w.key).push(rel);
511
+ }
512
+ }
513
+ return wants.filter((w) => hits.has(w.key)).map((w) => ({ key: w.key, tests: hits.get(w.key) }));
514
+ }
515
+
461
516
  // The ```page-tweak-applied``` block's content, from the page's NEW reading.
462
517
  // lines_after is the non-shared lines' texts: shared shell lines are left out of
463
518
  // drift on both sides (D8, builder pick 7), so they must be left out here too or
464
519
  // every one of them would read as "removed" on the next drift check.
465
- function appliedBlockFor({ pageId, readingPage, refused, appliedAt }) {
520
+ function appliedBlockFor({ pageId, readingPage, refused, appliedAt, pinnedTests = [] }) {
466
521
  return {
467
522
  page_id: pageId,
468
523
  applied_at: appliedAt,
469
524
  reading_hash_after: readingPage ? readingPage.reading_hash : null,
470
525
  lines_after: readingPage ? (readingPage.lines || []).filter((l) => l && l.placement !== 'shared').map((l) => String(l.text)) : [],
471
526
  refused: (refused || []).map((x) => ({ key: x.key, code: x.code })),
527
+ ...(pinnedTests && pinnedTests.length ? { pinned_tests: pinnedTests.map((p) => ({ key: p.key, tests: p.tests.slice() })) } : {}),
472
528
  };
473
529
  }
474
530
 
@@ -564,7 +620,10 @@ async function batchMain({ taskId, batchFile, dryRun, pageId = null, now = () =>
564
620
  return 4;
565
621
  }
566
622
  const readingPage = (deps.readingFor || ((id) => readingFor(id, { readFile })))(batch.page_id);
567
- const block = appliedBlockFor({ pageId: batch.page_id, readingPage, refused: result.refused, appliedAt: now() });
623
+ const oldText = new Map(batch.lines.map((l) => [l.key, l.before]));
624
+ const pinnedTests = (deps.findPinningTests || findPinningTests)({ applied: result.applied.map((a) => ({ key: a.key, before: oldText.get(a.key) })) });
625
+ for (const p of pinnedTests) log(` ${p.key} the old words are still asserted by: ${p.tests.join(', ')} (update the test or it fails once this lands)`);
626
+ const block = appliedBlockFor({ pageId: batch.page_id, readingPage, refused: result.refused, appliedAt: now(), pinnedTests });
568
627
  if (batchFile) {
569
628
  log('copy-apply --batch: the page-tweak-applied block (a --batch-file run writes no task):');
570
629
  log(pages.composeAppliedBlock(block));
@@ -684,6 +743,10 @@ module.exports = {
684
743
  applyBatchToSources,
685
744
  appliedBlockFor,
686
745
  batchMain,
746
+ // task 1004775: tests that still assert a rewritten line's old words.
747
+ findPinningTests,
748
+ listTestFiles,
749
+ pinNeedle,
687
750
  surfaceRootFor,
688
751
  checkPatchPath,
689
752
  locateOccurrence,
@@ -115,6 +115,8 @@ const REGENERABLE = new Set([
115
115
  'docs/session-log-index.md',
116
116
  'docs/copy-inventory.md',
117
117
  'docs/copy-registry.json',
118
+ // task 1004775: merged per page (page-reader.js mergeReadingDocs), see the rule below.
119
+ 'docs/page-readings.json',
118
120
  ...G.NESTED_DIRS.map((d) => `${d}/CLAUDE.md`),
119
121
  ...API_DOC_FILES,
120
122
  ...API_CLIENT_FILES,
@@ -170,6 +172,18 @@ function regenerateResolved(relpath, { base = '', ours = '', theirs = '' } = {})
170
172
  if (relpath === 'docs/copy-registry.json') {
171
173
  return { content: `${JSON.stringify(CI.buildRegistry(), null, 2)}\n`, ok: true };
172
174
  }
175
+ // docs/page-readings.json (task 1004775): one entry per page, and a re-read needs a
176
+ // browser no merge has. Two approved page-tweak rounds re-read DIFFERENT pages and
177
+ // both rewrite the top counts line, so every pair conflicted and the second round
178
+ // stranded for a person. Merge it per page from the three sides and recompute the
179
+ // counts (page-reader.js mergeReadingDocs). Unlike the scans above it reads the
180
+ // merge sides, not the tree, so it is not provisional; a page BOTH sides re-read
181
+ // differently is a real conflict and stays one.
182
+ if (relpath === 'docs/page-readings.json') {
183
+ const PR = require('./page-reader');
184
+ const m = PR.mergeReadingDocs({ base, ours, theirs });
185
+ return m.ok ? { content: m.content, ok: true } : { content: null, ok: false };
186
+ }
173
187
  if (relpath === 'docs/copy-inventory.md') {
174
188
  return { content: CI.renderReport(CI.buildRegistry()), ok: true };
175
189
  }
@@ -600,6 +600,50 @@ function mergeReadings(inventory, previous, fresh) {
600
600
  return inventory.pages.map((p) => byId.get(p.id)).filter(Boolean);
601
601
  }
602
602
 
603
+ // mergeReadingDocs({ base, ours, theirs }) -> { ok, content, conflicted }
604
+ //
605
+ // The merge rule for docs/page-readings.json (task 1004775). Two approved page
606
+ // tweak rounds branched from the same main each re-read THEIR page, and both
607
+ // rewrite the top `counts` line — so git conflicts on every pair, and the second
608
+ // round stranded as unresolvable_conflict until a person merged it by hand. A
609
+ // re-read needs a browser, which a merge never has, but it does not need one: the
610
+ // file is one entry per page, so the merge is per page. A page only one side
611
+ // changed takes that side's reading; a page both changed the same way takes it; a
612
+ // page both changed DIFFERENTLY is a real conflict (two rounds re-read one page —
613
+ // their source files conflict too) and is named, never guessed. The summary counts
614
+ // are recomputed from the merged pages and the whole file re-serialized, so the
615
+ // result is byte-for-byte what the reader itself would write for those pages.
616
+ // Pages keep ours' order; a page only theirs has goes where theirs had it.
617
+ function mergeReadingDocs({ base, ours, theirs }) {
618
+ const parse = (s) => { try { return s && String(s).trim() ? JSON.parse(s) : { pages: [] }; } catch { return null; } };
619
+ const b = parse(base), o = parse(ours), t = parse(theirs);
620
+ if (!b || !o || !t) return { ok: false, content: null, conflicted: ['(unreadable json)'] };
621
+ const map = (d) => new Map((d.pages || []).map((p) => [p.id, p]));
622
+ const B = map(b), O = map(o), T = map(t);
623
+ const same = (x, y) => JSON.stringify(x === undefined ? null : x) === JSON.stringify(y === undefined ? null : y);
624
+ const order = [...O.keys()];
625
+ for (const id of T.keys()) {
626
+ if (order.includes(id)) continue;
627
+ const at = (t.pages || []).findIndex((p) => p.id === id);
628
+ const prev = at > 0 ? t.pages[at - 1].id : null;
629
+ const i = prev ? order.indexOf(prev) : -1;
630
+ order.splice(i + 1, 0, id);
631
+ }
632
+ const pages = [];
633
+ const conflicted = [];
634
+ for (const id of order) {
635
+ const bv = B.get(id), ov = O.get(id), tv = T.get(id);
636
+ let pick;
637
+ if (same(ov, tv)) pick = ov;
638
+ else if (same(ov, bv)) pick = tv;
639
+ else if (same(tv, bv)) pick = ov;
640
+ else { conflicted.push(id); continue; }
641
+ if (pick) pages.push(pick);
642
+ }
643
+ if (conflicted.length) return { ok: false, content: null, conflicted };
644
+ return { ok: true, content: serialize(buildDoc(pages)), conflicted: [] };
645
+ }
646
+
603
647
  function loadPrevious() {
604
648
  try { return readJsonFile(READINGS_REL); } catch { return null; }
605
649
  }
@@ -671,6 +715,8 @@ module.exports = {
671
715
  filesHash, readingHash, buildMatcher, placeText, collectStrings, holeData,
672
716
  mergeStates, assembleReading, serialize, buildDoc, checkReadings, planRenders,
673
717
  captureInPage, mergeReadings,
718
+ // task 1004775: the merge driver's per-page rule for docs/page-readings.json.
719
+ mergeReadingDocs,
674
720
  // task 1004322: /tweak renders the page the reader reads, through the same harness.
675
721
  startHarness, SURFACE_MODULE, PINNED_CLOCK,
676
722
  };
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.163'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.164'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -591,7 +591,13 @@ test('1003546 every REGENERABLE artifact has a producer, and every producer is i
591
591
  .map((p) => [p, 'scripts/gds/gen-api-client.js']),
592
592
  ]);
593
593
 
594
+ // task 1004775 — docs/page-readings.json is merged per page FROM THE THREE MERGE
595
+ // SIDES (page-reader.js mergeReadingDocs), never by scanning the tree, so the
596
+ // driver's result is already final; its only producer opens a browser, which the
597
+ // server never has. It is the one REGENERABLE entry with no post-merge generator.
598
+ const MERGED_FROM_SIDES = new Set(['docs/page-readings.json']);
594
599
  for (const p of regen.REGENERABLE) {
600
+ if (MERGED_FROM_SIDES.has(p)) continue;
595
601
  const producer = PRODUCED_BY.get(p);
596
602
  assert.ok(producer, `REGENERABLE knows ${p} but this test has no producer for it — name the generator that writes it`);
597
603
  assert.ok(
@@ -380,4 +380,20 @@ await test('the dialog fits the viewport at every width: capped by the screen, t
380
380
  assert.match(rule('.aq-line__text'), /overflow-wrap: anywhere/, 'a long word breaks inside its column');
381
381
  });
382
382
 
383
+ // task 1004775: a test the applier found still asserting a line's old words is
384
+ // shown on that line, so the artist knows before approving, not CI after.
385
+ await test('a line whose old words a test still checks says so, through the real composer', () => {
386
+ const description = [
387
+ pages.composeBatchBlock({ page_id: 'landing:index', reading_hash: 'h', submitted_at: '2026-09-19T00:00:00Z', lines: [
388
+ { key: 'L0003', section: 'hero', before: 'AI agents claim tasks and ship the code.', after: 'Agents write the code.', placement: 'placed', file: 'x.html', line: 1, string_id: 'a' },
389
+ ] }),
390
+ pages.composeAppliedBlock({ page_id: 'landing:index', applied_at: '2026-09-21T00:00:00Z', reading_hash_after: 'h2', lines_after: ['Agents write the code.'], refused: [], pinned_tests: [{ key: 'L0003', tests: ['tests/landing_page.mjs'] }] }),
391
+ ].join('\n\n');
392
+ const tasks = [{ id: 51, status: 'completed', grade_passed: true, description, source_ref: pages.roundSourceRef('landing:index', 1), holder_id: null, artist_id: ME, artist_login: 'me' }];
393
+ const q = pageStatus.composeApprovals({ inventory: INVENTORY, tasks, builderId: ME, crafts: ['artist'] });
394
+ const line = q.queue[0].lines[0];
395
+ assert.deepEqual(line.pinned_by, ['tests/landing_page.mjs']);
396
+ assert.equal(lib.lineRow(line, 'after').pinned, 'A test still checks the old words: tests/landing_page.mjs. It needs updating when this ships.');
397
+ });
398
+
383
399
  summary();
@@ -266,7 +266,7 @@ test('the Standing card is the mock\'s: an identity line and a three-row fact le
266
266
  assert.match(block[0], /class="home-who"/, 'the identity line');
267
267
  assert.match(block[0], /<dt>\$\{escapeHtml\(curLabel\(true\)\)\}<\/dt>/,
268
268
  'the currency row takes its label from the branding contract at render time — a data-currency-label attribute is rebranded once at load, before this row exists');
269
- for (const label of ['On the board', 'Preferred work']) {
269
+ for (const label of ['Leaderboard place', 'Preferred work']) {
270
270
  assert.match(block[0], new RegExp(`<dt>${label}</dt>`), `the ledger row: ${label}`);
271
271
  }
272
272
  assert.match(block[0], /: PREFS_EMPTY\}/, 'the empty-preference copy is the one string both Standing surfaces share');
@@ -278,7 +278,8 @@ test('the Standing card is the mock\'s: an identity line and a three-row fact le
278
278
  });
279
279
 
280
280
  test('the system notices are inbox LINES, first in the card, and counted by its refresh()', () => {
281
- const mount = goalInboxJs.match(/host\.innerHTML =([\s\S]*?);/);
281
+ // Anchored on the statement AFTER the markup, so a ';' inside the markup or its comments cannot cut it short.
282
+ const mount = goalInboxJs.match(/host\.innerHTML =([\s\S]*?);\n host\.addEventListener/);
282
283
  assert.ok(mount, 'the inbox mounts its slices');
283
284
  const sys = mount[1].indexOf('id="attention-system"');
284
285
  const goals = mount[1].indexOf('id="attention-goals"');
@@ -378,7 +379,7 @@ test('RUN: the greeting names the builder, and the sub-line says how far the ver
378
379
  const { api, el } = bootHallRender({ me: ME, progress: PROGRESS, claimable: 5 });
379
380
  api.renderGreeting();
380
381
  assert.match(el('hall-h1').textContent, /^Good (morning|afternoon|evening), Percy Jackson$/);
381
- assert.equal(el('hall-sub').textContent, 'BONGOS-V1 is 75 percent shipped. Five tasks are claimable right now.');
382
+ assert.equal(el('hall-sub').textContent, 'BONGOS-V1 is 75 percent complete. Five tasks are claimable right now.');
382
383
  assert.equal(el('hall-sub').hidden, false);
383
384
  });
384
385
 
@@ -388,7 +389,7 @@ test('RUN: each half of the sentence renders as its data lands, and the line hid
388
389
  assert.equal(none.el('hall-sub').hidden, true, 'nothing to say yet');
389
390
  const onlyProgress = bootHallRender({ me: ME, progress: PROGRESS });
390
391
  onlyProgress.api.renderGreeting();
391
- assert.equal(onlyProgress.el('hall-sub').textContent, 'BONGOS-V1 is 75 percent shipped.');
392
+ assert.equal(onlyProgress.el('hall-sub').textContent, 'BONGOS-V1 is 75 percent complete.');
392
393
  const zero = bootHallRender({ me: ME, progress: PROGRESS, claimable: 0 });
393
394
  zero.api.renderGreeting();
394
395
  assert.match(zero.el('hall-sub').textContent, /Nothing is claimable right now\.$/);
@@ -409,13 +410,13 @@ test('RUN: the Standing card carries the three facts, the place as "2nd of 4", a
409
410
  // written from curLabel(true), not a data-currency-label attribute: brand-apply
410
411
  // rebrands those once at load, and this row is stamped after /me answers
411
412
  assert.match(html, /<dt>Credits<\/dt><dd>14,441<\/dd>/);
412
- assert.match(html, /<dt>On the board<\/dt><dd>2nd of 4<\/dd>/);
413
+ assert.match(html, /<dt>Leaderboard place<\/dt><dd>2nd of 4<\/dd>/);
413
414
  assert.match(html, /<dt>Preferred work<\/dt><dd>Not set yet<\/dd>/, 'the mock\'s copy for an empty preference');
414
415
  assert.doesNotMatch(html, /profile__(needs|budget|rebase)/, 'the banners are not in the card');
415
416
  const withPrefs = bootHallRender({ me: ME, leaderboard: [] });
416
417
  withPrefs.api.renderProfile({ ...ME.builder, preferred_disciplines: ['engineer', 'ui'] });
417
418
  assert.match(withPrefs.el('home-standing-body').innerHTML, /<dd>engineer, ui<\/dd>/);
418
- assert.match(withPrefs.el('home-standing-body').innerHTML, /<dt>On the board<\/dt><dd>—<\/dd>/, 'no roll yet: a dash, not a wrong place');
419
+ assert.match(withPrefs.el('home-standing-body').innerHTML, /<dt>Leaderboard place<\/dt><dd>—<\/dd>/, 'no roll yet: a dash, not a wrong place');
419
420
  });
420
421
 
421
422
  test('RUN: the three notices are inbox lines with a kind, a line and — where one exists — a door', () => {
@@ -0,0 +1,119 @@
1
+ // tests/home_attention_cards.mjs — the Needs-attention CARDS and their window (task 1004774).
2
+ //
3
+ // WHY. Home's attention inbox became one card per slice, and a click moves the slice itself into a
4
+ // <dialog> so its buttons keep working through the one delegated handler. That is DOM behaviour —
5
+ // moving nodes, the native dialog's open/close, a card derived from a heading — so it runs the REAL
6
+ // goal-inbox.js in a real browser (the ui-design kit's launcher) over stubbed feeds, rather than
7
+ // grepping the source. Skips when the machine has no system Edge or Chrome, as ui_design_kit_fit does.
8
+ import assert from 'node:assert/strict';
9
+ import { test, after } from 'node:test';
10
+ import { createRequire } from 'node:module';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const require = createRequire(import.meta.url);
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const lib = require(path.join(ROOT, 'modules', 'ui-design', 'kit', 'lib.js'));
18
+ const GOAL_INBOX = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'goal-inbox.js'), 'utf8');
19
+
20
+ let browser = null;
21
+ try { browser = await lib.launchBrowser(); } catch (e) { if (!(e instanceof lib.NoBrowserError)) throw e; }
22
+ after(() => browser && browser.close());
23
+
24
+ // Two feeds answer: the blockers queue (2 rows shown of a server total of 7) and the rot feed
25
+ // (1 row, with its timer). Every other feed 404s, which the inbox treats as "nothing here".
26
+ const STUBS = `
27
+ window.__FEEDS = {
28
+ '/api/bongos/blockers?limit=5': { blockers: [{ id: 1, title: 'Key needed' }, { id: 2, title: 'Pick a host' }], page: { total: 7 } },
29
+ '/api/bongos/inbox/rotting': { timer: { rot_days: 30 }, rows: [{ tier: 'task', id: 9, title: 'Old task', status: 'ready', goal_id: 3,
30
+ verbs: [{ verb: 'water', label: 'Water', path: '/api/bongos/tasks/:id/water' }] }] },
31
+ };
32
+ window.BongosClient = { createClient: () => ({ request: async (m, p) => {
33
+ const d = window.__FEEDS[p];
34
+ return d ? { ok: true, status: 200, data: d } : { ok: false, status: 404, data: null };
35
+ } }) };
36
+ window.OTBKit = { esc: (s) => String(s), fmtAbsDate: () => 'Oct 1' };
37
+ window.OTBBlockersLib = { blockerFacts: (b) => ({ titleHtml: '<a class="ginbox__goal">' + b.title + '</a>', factsHtml: '', noteHtml: '' }) };
38
+ window.OTB = {
39
+ escapeHtml: (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'),
40
+ toast() {}, rankLabel: (r) => r,
41
+ contributeHallWidget(w) { window.__widget = w; },
42
+ };`;
43
+
44
+ const PAGE = '<!doctype html><html><body><section id="attention-scroll"></section></body></html>';
45
+
46
+ async function bootInbox() {
47
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
48
+ const page = await ctx.newPage();
49
+ await page.setContent(PAGE);
50
+ await page.addScriptTag({ content: STUBS });
51
+ await page.addScriptTag({ content: GOAL_INBOX });
52
+ await page.evaluate(() => window.__widget.mount(document.getElementById('attention-scroll')));
53
+ await page.waitForFunction(() => document.querySelectorAll('.attn-card').length === 2);
54
+ return { ctx, page };
55
+ }
56
+
57
+ const cards = (page) => page.evaluate(() => [...document.querySelectorAll('.attn-card')].map((c) => ({
58
+ open: c.getAttribute('data-attn-open'),
59
+ n: c.querySelector('.attn-card__n').textContent,
60
+ t: c.querySelector('.attn-card__t').textContent,
61
+ s: c.querySelector('.attn-card__s') ? c.querySelector('.attn-card__s').textContent : null,
62
+ })));
63
+
64
+ test('one card per non-empty slice, titled from its heading, carrying the SERVER total', { skip: !browser && 'no system Edge or Chrome' }, async () => {
65
+ const { ctx, page } = await bootInbox();
66
+ const got = await cards(page);
67
+ await ctx.close();
68
+ // Slice order is the card order: blockers sit before rot in the mount.
69
+ assert.deepEqual(got, [
70
+ { open: 'attention-blockers', n: '7', t: 'Requires project owner fix', s: null },
71
+ { open: 'attention-rot', n: '1', t: 'Things not moving', s: 'untouched 30+ days' },
72
+ ], 'the blockers card says 7 (page.total), not the 2 rows the slice drew; empty slices get no card');
73
+ });
74
+
75
+ test('a card opens its slice in the window, and the X puts it back', { skip: !browser && 'no system Edge or Chrome' }, async () => {
76
+ const { ctx, page } = await bootInbox();
77
+ await page.click('[data-attn-open="attention-rot"]');
78
+ const opened = await page.evaluate(() => ({
79
+ open: document.getElementById('attention-dialog').open,
80
+ title: document.getElementById('attention-dialog-h').textContent,
81
+ inDialog: !!document.querySelector('#attention-dialog-body > #attention-rot'),
82
+ verb: !!document.querySelector('#attention-dialog [data-rot-verb="water"]'),
83
+ }));
84
+ assert.deepEqual(opened, { open: true, title: 'Things not moving', inDialog: true, verb: true },
85
+ 'the SAME slice node moved into the dialog, with its action buttons');
86
+ await page.click('#attention-dialog-x');
87
+ const closed = await page.evaluate(() => ({
88
+ open: document.getElementById('attention-dialog').open,
89
+ back: !!document.querySelector('#attention-slices > #attention-rot'),
90
+ cards: document.querySelectorAll('.attn-card').length,
91
+ }));
92
+ await ctx.close();
93
+ assert.deepEqual(closed, { open: false, back: true, cards: 2 });
94
+ });
95
+
96
+ test('closing natively (Escape) also returns the slice, so a reopen still finds it', { skip: !browser && 'no system Edge or Chrome' }, async () => {
97
+ const { ctx, page } = await bootInbox();
98
+ await page.click('[data-attn-open="attention-blockers"]');
99
+ await page.keyboard.press('Escape');
100
+ await page.waitForFunction(() => !document.getElementById('attention-dialog').open);
101
+ const back = await page.evaluate(() => !!document.querySelector('#attention-slices > #attention-blockers'));
102
+ assert.equal(back, true, 'the dialog close event moves the slice home');
103
+ await page.click('[data-attn-open="attention-blockers"]');
104
+ const rows = await page.evaluate(() => document.querySelectorAll('#attention-dialog-body .ginbox__item').length);
105
+ await ctx.close();
106
+ assert.equal(rows, 2, 'the reopened window shows the slice rows again');
107
+ });
108
+
109
+ test('a window whose slice empties while open closes itself', { skip: !browser && 'no system Edge or Chrome' }, async () => {
110
+ const { ctx, page } = await bootInbox();
111
+ await page.click('[data-attn-open="attention-rot"]');
112
+ await page.evaluate(() => { document.getElementById('attention-rot').innerHTML = ''; window.OTBAttention.refresh(); });
113
+ const state = await page.evaluate(() => ({
114
+ open: document.getElementById('attention-dialog').open,
115
+ cards: [...document.querySelectorAll('.attn-card')].map((c) => c.getAttribute('data-attn-open')),
116
+ }));
117
+ await ctx.close();
118
+ assert.deepEqual(state, { open: false, cards: ['attention-blockers'] });
119
+ });
@@ -60,7 +60,7 @@ await test('no Home read downloads a feed and slices it in the browser', () => {
60
60
  });
61
61
 
62
62
  await test('the two cards ask for exactly the rows they draw', () => {
63
- assert.match(BUILDERS_JS, /\$\{API\}\/tasks\/claimable\?limit=2/, 'Next up draws two rows');
63
+ assert.match(BUILDERS_JS, /\$\{API\}\/tasks\/claimable\?limit=5/, 'Next up draws five rows, level with Recently shipped');
64
64
  assert.match(BUILDERS_JS, /\$\{API\}\/inbox\?limit=5/, 'the triage card draws five');
65
65
  // …and no longer re-slices what came back.
66
66
  const src = noComments(BUILDERS_JS);
@@ -227,6 +227,14 @@ await test('BEHAVIOUR: a failed create throws with the status, it does not retur
227
227
  // STATIC — the surfaces a unit test cannot invoke (browser DOM strings, docs).
228
228
  // ==========================================================================
229
229
 
230
+ // The hall promote once sent no discipline, and an idea whose wording names no
231
+ // craft was refused "discipline was not supplied and could not be inferred".
232
+ await test('BOTH PATHS: the hall promote payload always sends a discipline', () => {
233
+ assert.match(HALL, /discipline: \(form\.discipline && form\.discipline\.value\) \|\| 'engineer'/,
234
+ 'hall: discipline must reach the create payload, defaulting to engineer');
235
+ assert.match(HALL, /<select name="discipline">/, 'hall: the promote form offers a Craft picker');
236
+ });
237
+
230
238
  // ---- 4. both paths send goal_id -------------------------------------------
231
239
 
232
240
  await test('BOTH PATHS: the CLI createTask and the hall payload each send goal_id', () => {
@@ -198,6 +198,8 @@ function harness({ batch, source = 'page-tweak', registryOverride = null, reread
198
198
  registry: REG,
199
199
  regenerateRegistry: () => ({ reg: registryOverride || registryOver(Object.fromEntries(Object.keys(tree).map((rel) => [rel, files.get(abs(rel))]))) }),
200
200
  reread: () => (rereadOk ? { ok: true } : { ok: false, code: 'no_browser' }),
201
+ // Hermetic by default: the real finder scans the repo's own tests (task 1004775's case below injects a tree).
202
+ findPinningTests: () => [],
201
203
  readingFor: () => ({ reading_hash: 'r-after', lines: [{ key: 'L0001', text: 'Skip to content', placement: 'shared' }, { key: 'L0002', text: 'Welcome to your studio', placement: 'placed' }] }),
202
204
  api: async (method, url, body) => {
203
205
  calls.push({ method, url, body });
@@ -299,4 +301,42 @@ await test("--page: the round's own page applies as usual", async () => {
299
301
  assert.ok(h.writes.length > 0);
300
302
  });
301
303
 
304
+ // task 1004775: a test that asserts a rewritten line's OLD words verbatim is named
305
+ // in the applied block at apply time, not discovered by CI after the artist approved.
306
+ await test('copy-apply names a test that pins a line\'s old text in the applied block', async () => {
307
+ const testTree = {
308
+ 'tests/landing_fixture.mjs': "assert.match(html, /x/);\nassert.ok(html.includes('Take a page and rewrite every word of it.'));\n",
309
+ 'modules/copy-desk/tests/other.mjs': "assert.ok(s.includes('nothing relevant here'));\n",
310
+ 'tests/short_label.mjs': "assert.ok(s.includes('Take next'));\n",
311
+ };
312
+ const h = harness({ batch: BATCH() });
313
+ h.deps.findPinningTests = (args) => apply.findPinningTests({ ...args, listTests: () => Object.keys(testTree), readFile: (rel) => testTree[rel] });
314
+ const logs = [];
315
+ h.deps.log = (s) => logs.push(s);
316
+ const code = await apply.batchMain({ taskId: '5001', now: () => 't' }, h.deps);
317
+ assert.equal(code, 0);
318
+ const applied = pages.parseAppliedBlock(h.calls.find((c) => c.method === 'PATCH').body.description);
319
+ assert.deepEqual(applied.applied.pinned_tests, [{ key: 'L0002', tests: ['tests/landing_fixture.mjs'] }],
320
+ 'the pinning test is named against its line; a short label (Take next) is too common to search for');
321
+ assert.ok(logs.some((l) => /L0002 the old words are still asserted by: tests\/landing_fixture\.mjs/.test(l)), 'and said on the terminal');
322
+ assert.equal(testTree['tests/landing_fixture.mjs'].includes('rewrite every word'), true, 'the test itself is never edited');
323
+ });
324
+
325
+ await test('a hole in the old text searches its longest literal run', () => {
326
+ assert.equal(apply.pinNeedle('You have {…} pages waiting for review'), 'pages waiting for review');
327
+ assert.equal(apply.pinNeedle('Take next'), null);
328
+ const hits = apply.findPinningTests({
329
+ applied: [{ key: 'L0003', before: 'You have {…} pages waiting for review' }],
330
+ listTests: () => ['tests/a.mjs'], readFile: () => '`You have ${n} pages waiting for review`',
331
+ });
332
+ assert.deepEqual(hits, [{ key: 'L0003', tests: ['tests/a.mjs'] }]);
333
+ });
334
+
335
+ await test('the real test lister walks tests/ and modules/*/tests/, skipping fixtures', () => {
336
+ const list = apply.listTestFiles();
337
+ assert.ok(list.includes('tests/tweak_batch_apply.mjs'));
338
+ assert.ok(list.includes('modules/copy-desk/tests/copy_page_status.mjs'));
339
+ assert.ok(!list.some((f) => f.includes('/fixtures/')));
340
+ });
341
+
302
342
  summary();