@bongos/core 1.21.25 → 1.21.27

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 (52) hide show
  1. package/.bongos-core.json +56 -56
  2. package/.claude/skills/idea-triage/SKILL.md +3 -3
  3. package/docs/copy-inventory.md +8 -8
  4. package/docs/copy-registry.json +9 -9
  5. package/docs/module-api-changelog.md +4 -0
  6. package/docs/page-readings.json +87 -87
  7. package/modules/hall-ui/public/atlas.html +1 -1
  8. package/modules/hall-ui/public/blockers.html +1 -1
  9. package/modules/hall-ui/public/board-room.html +1 -1
  10. package/modules/hall-ui/public/city.html +1 -1
  11. package/modules/hall-ui/public/collab.html +1 -1
  12. package/modules/hall-ui/public/commands.html +1 -1
  13. package/modules/hall-ui/public/copy-desk.html +1 -1
  14. package/modules/hall-ui/public/deploy.html +1 -1
  15. package/modules/hall-ui/public/diagrams.html +1 -1
  16. package/modules/hall-ui/public/drachmae.html +1 -1
  17. package/modules/hall-ui/public/fleet.html +1 -1
  18. package/modules/hall-ui/public/gate.html +1 -1
  19. package/modules/hall-ui/public/goal-map.html +1 -1
  20. package/modules/hall-ui/public/goals.html +1 -1
  21. package/modules/hall-ui/public/government.html +1 -1
  22. package/modules/hall-ui/public/idea.html +1 -1
  23. package/modules/hall-ui/public/ideas.html +1 -1
  24. package/modules/hall-ui/public/index.html +1 -1
  25. package/modules/hall-ui/public/modules.html +1 -1
  26. package/modules/hall-ui/public/primer.html +1 -1
  27. package/modules/hall-ui/public/profile.html +1 -1
  28. package/modules/hall-ui/public/project-settings.html +1 -1
  29. package/modules/hall-ui/public/ranks.html +1 -1
  30. package/modules/hall-ui/public/roadmap.html +1 -1
  31. package/modules/hall-ui/public/roster.html +1 -1
  32. package/modules/hall-ui/public/sessions.html +1 -1
  33. package/modules/hall-ui/public/settings.html +1 -1
  34. package/modules/hall-ui/public/shell.js +97 -60
  35. package/modules/hall-ui/public/street.html +1 -1
  36. package/modules/hall-ui/public/studio.html +1 -1
  37. package/modules/hall-ui/public/style.css +46 -0
  38. package/modules/hall-ui/public/task.html +1 -1
  39. package/modules/hall-ui/public/thinking.html +1 -1
  40. package/modules/hall-ui/public/tweak-editor.html +1 -1
  41. package/modules/hall-ui/public/watch.html +1 -1
  42. package/modules/hall-ui/public/work.html +1 -1
  43. package/modules/hall-ui/records/hall-navigation.md +7 -2
  44. package/package-lock.json +2 -2
  45. package/package.json +1 -1
  46. package/release-notes.json +12 -0
  47. package/scripts/gds/triage.js +14 -8
  48. package/src/module-api.js +1 -1
  49. package/tests/hall_fullscreen.mjs +122 -5
  50. package/tests/hall_tweak_editor.mjs +1 -1
  51. package/tests/idea_triage.mjs +40 -0
  52. package/tests/nav_permission_atoms.mjs +1 -1
@@ -162,7 +162,7 @@
162
162
 
163
163
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
164
164
  chrome exists before the page script runs. -->
165
- <script src="/builders/shell.js?v=2026-10-02-back"></script>
165
+ <script src="/builders/shell.js?v=2026-10-02-trail"></script>
166
166
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
167
167
  <script src="/builders/palette.js" defer></script>
168
168
  <!-- shared frontend toolkit (window.OTB) — loaded before watch.js, which pulls from it. -->
@@ -171,7 +171,7 @@
171
171
 
172
172
  <!-- The app shell (sidebar + top bar + footer). Dependency-free; runs at parse
173
173
  time so the chrome exists before the deferred page scripts execute. -->
174
- <script src="/builders/shell.js?v=2026-10-02-back"></script>
174
+ <script src="/builders/shell.js?v=2026-10-02-trail"></script>
175
175
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
176
176
  <script src="/builders/palette.js" defer></script>
177
177
  <!-- shared frontend toolkit (window.OTB) — loaded FIRST so work.js can pull from it. -->
@@ -1,4 +1,4 @@
1
- # Hall navigation — the Back link and the standard full-screen switch (tasks 1004430, 1004477)
1
+ # Hall navigation — the Back link and its trail, and the standard full-screen switch (tasks 1004430, 1004477, 1004476)
2
2
 
3
3
  Covers: every shelled hall page (`shell.js`, `style.css`), plus `goal-map.html` joining the chrome.
4
4
 
@@ -11,6 +11,11 @@ Covers: every shelled hall page (`shell.js`, `style.css`), plus `goal-map.html`
11
11
  - `<body data-back-fallback="/work">` names where Back goes when there is no origin (a deep-linked task falls back to Tasks, an idea to Your thinking, the tweak editor to Studio). It is honoured only as a path on this host; a sidebar hop still shows nothing.
12
12
  - A link marked `[data-shell-exit]` is a second exit a page places: the task record's actions row and its two failure renders, the primer and drachmae error notes, and the tweak editor's bar. The shell fills its `href` and its words ("Back to goal 12", "Back to tasks"), and with no way back at all it goes home. A page that renders one after boot calls `OTBShell.fillExits(root)`.
13
13
  - The origin's own `?from=` is dropped so back links never chain, and a `?from=` naming the page itself is refused.
14
+ - **The trail (task 1004476).** Two or more clicks deep the Back link grows into a breadcrumb: "‹ Goals › goal 12 › task 340", each crumb a link and the last one `#topbar-back` itself. One step back renders exactly the old Back link.
15
+ - It lives per tab in `sessionStorage['hall-nav-trail']`, a list of `{href,label}` crumbs ending with the current page. A drill-down (origin found in the trail) cuts after the origin and appends; returning to a crumb (this page is in the trail and the origin sits after it, or there is none) trims after it; an origin the trail does not know starts fresh, so `?from=`, referrer and `data-back-fallback` still decide the first crumb; a sidebar hop starts over with no trail.
16
+ - A referrer has no `#hash`, so a referrer origin matches the latest crumb on the same address: a task opened from `/goals#/goal/12` finds "goal 12", not "Goals". Hash routes (a goal opened on `/goals`, the hall's overlays) update the trail on `hashchange`: a record hash appends, a held crumb trims, any other view only renames the page's own crumb.
17
+ - Capped at four crumbs, oldest dropped. Stored crumbs pass the same host-path check as the fallback and are escaped when drawn. Storage that throws keeps the one-step Back.
18
+ - Width folds it: at 640px the middle becomes "…"; at 480px the first crumb joins it, the "…" becomes a link to the trail's start (titled with what it hides), and the bar's page name hides while a trail shows; at 360px only the Back arrow is left. Measured with no sideways scroll at 320, 390, 600 and 1280.
14
19
  - `/profile?id=N` highlights **Builders**, not your own Profile, unless `/me` says the id is yours.
15
20
 
16
21
  ## Full screen is standard
@@ -22,4 +27,4 @@ Covers: every shelled hall page (`shell.js`, `style.css`), plus `goal-map.html`
22
27
 
23
28
  `goal-map.html` now uses thinking.html's structure: sidebar and top-bar mounts, `style.css`, `shell.js`, the sky inside `<main id="hall-main" class="sky-stage">`, and a `<div class="brand">` because the shell's bar is the page's one `<header>`. In `thinking.css` both pages share one stage rule, `body[data-page] .sky-stage`. No hall page is standalone any more (`tests/hall_audit.mjs` `STANDALONE_EXEMPT` is empty).
24
29
 
25
- Tests: `tests/hall_fullscreen.mjs` (the standard switch, `plain`, Back link, fallback and exit cases), `tests/hall_record_world.mjs` §10 (the record places its exit), `tests/goal_map_page.mjs`, `tests/hall_audit.mjs` §8.
30
+ Tests: `tests/hall_fullscreen.mjs` (the standard switch, `plain`, Back link, trail, fallback and exit cases), `tests/hall_record_world.mjs` §10 (the record places its exit), `tests/goal_map_page.mjs`, `tests/hall_audit.mjs` §8.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.25",
3
+ "version": "1.21.27",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.25",
9
+ "version": "1.21.27",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.25",
3
+ "version": "1.21.27",
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",
@@ -8906,5 +8906,17 @@
8906
8906
  "id": "1004533",
8907
8907
  "text": "Twelve goal 1000045 tasks that were counted toward other goals' criteria now count toward their own, so that goal's progress reads true."
8908
8908
  }
8909
+ ],
8910
+ "1.21.26": [
8911
+ {
8912
+ "id": "1003623",
8913
+ "text": "Fixed idea-triage cluster handling: 'keep #N' and 'merge #N' now parse; skipped clusters count toward the summary; spark ideas excluded from batch clustering; stale Step 1.5 refs now point at 1.5b. Tests added in tests/idea_tr"
8914
+ }
8915
+ ],
8916
+ "1.21.27": [
8917
+ {
8918
+ "id": "1004476",
8919
+ "text": "In the builders' hall, a page you reach through several clicks now shows the whole way back at the top, like Goals > goal 12 > task 340, and each step is a link. Clicking an earlier step takes you straight there; the sidebar s"
8920
+ }
8909
8921
  ]
8910
8922
  }
@@ -400,10 +400,12 @@ function clusterRecords(records, { textOf, kindOf, timeOf, threshold = 0.34, min
400
400
 
401
401
  // The idea-shaped call: Full Ideas are never eligible (they get their own
402
402
  // shape recommendation, SKILL.md Step 2.5, and batching one away would skip
403
- // that judgment), clustered on title+body_md / kind / captured_at. Same
404
- // signature and behavior as before the clusterRecords extraction.
403
+ // that judgment), nor are sparks (not triage's to convert, SKILL.md); the rest
404
+ // cluster on title+body_md / kind / captured_at. Same signature as before the
405
+ // clusterRecords extraction.
406
+ const NON_CLUSTER_GRADES = new Set(['full', 'spark']);
405
407
  function clusterIdeas(ideas, { threshold = 0.34, minShared = 2 } = {}) {
406
- const eligible = ideas.filter((i) => (i.grade || 'quick') !== 'full');
408
+ const eligible = ideas.filter((i) => !NON_CLUSTER_GRADES.has(i.grade || 'quick'));
407
409
  return clusterRecords(eligible, {
408
410
  textOf: (i) => `${i.title || ''} ${i.body_md || ''}`,
409
411
  kindOf: (i) => i.kind || null,
@@ -431,7 +433,7 @@ async function handleCluster(rl, cluster, clusteredIds, state) {
431
433
  while (!handled) {
432
434
  const ans = (await prompt(rl, ` [k]eep #${cluster[0].id} & merge the rest / [d]iscard all / [i]ndividually / [s]kip cluster > `)).trim().toLowerCase();
433
435
 
434
- if (ans === 's' || ans === 'skip' || ans === '') { handled = true; break; }
436
+ if (ans === 's' || ans === 'skip' || ans === '') { state.summary.skipped += cluster.length; handled = true; break; }
435
437
 
436
438
  if (ans === 'i' || ans === 'individually') {
437
439
  for (const m of cluster) clusteredIds.delete(m.id);
@@ -458,7 +460,7 @@ async function handleCluster(rl, cluster, clusteredIds, state) {
458
460
  handled = true; break;
459
461
  }
460
462
 
461
- if (ans === 'k' || ans === 'keep' || ans.startsWith('k ') || ans.startsWith('k#')) {
463
+ if (/^k(eep)?(\s|#|$)/.test(ans)) {
462
464
  let canonicalId = cluster[0].id;
463
465
  const inline = ans.replace(/^k(eep)?(\s|#)*/, '').trim();
464
466
  if (inline) {
@@ -535,6 +537,9 @@ async function main() {
535
537
  await handleCluster(rl, cluster, clusteredIds, state);
536
538
  }
537
539
  }
540
+ // Cluster verdicts bump the summary too, but those ideas are already out of
541
+ // `ideas`; remember the tally so "unseen" only counts the individual walk.
542
+ const clusterHandled = summary.promoted + summary.discarded + summary.merged + summary.skipped;
538
543
  ideas = ideas.filter((i) => !clusteredIds.has(i.id));
539
544
  if (ideas.length === 0) {
540
545
  console.log('\nnothing left to walk individually — every open idea was resolved in a batch cluster verdict.');
@@ -593,10 +598,10 @@ async function main() {
593
598
  break;
594
599
  }
595
600
 
596
- if (ans === 'm' || ans === 'merge' || ans.startsWith('m ') || ans.startsWith('m#')) {
601
+ if (/^m(erge)?(\s|#|$)/.test(ans)) {
597
602
  // Accept "m 12" or "m #12" inline, otherwise prompt.
598
603
  let target = null;
599
- const inline = ans.replace(/^m(\s|#)*/, '').trim();
604
+ const inline = ans.replace(/^m(erge)?(\s|#)*/, '').trim();
600
605
  if (inline) target = Number(inline.replace(/^#/, ''));
601
606
  if (!Number.isFinite(target) || target <= 0) {
602
607
  const a = (await prompt(rl, ' merge into idea #: ')).trim().replace(/^#/, '');
@@ -700,7 +705,7 @@ async function main() {
700
705
  rl.close();
701
706
  }
702
707
 
703
- const walked = summary.promoted + summary.discarded + summary.merged + summary.skipped;
708
+ const walked = summary.promoted + summary.discarded + summary.merged + summary.skipped - clusterHandled;
704
709
  const remaining = ideas.length - walked;
705
710
 
706
711
  console.log('\n' + '═'.repeat(72));
@@ -730,6 +735,7 @@ module.exports = {
730
735
  loadDevelopment,
731
736
  formatDevelopment,
732
737
  clusterIdeas,
738
+ handleCluster,
733
739
  clusterRecords,
734
740
  significantWords,
735
741
  jaccard,
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.25'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.27'; // 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');
@@ -33,7 +33,8 @@ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
33
33
  // <body data-back-fallback> and `exits` how many [data-shell-exit] links the
34
34
  // page placed for the shell to fill (task 1004477).
35
35
  function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = false,
36
- referrer = '', search = '', session = {}, fallback = null, exits = 0, path = null, meId = null } = {}) {
36
+ referrer = '', search = '', session = {}, fallback = null, exits = 0, path = null, meId = null,
37
+ hash = '', sessionThrows = false } = {}) {
37
38
  const els = new Map();
38
39
  const windowListeners = {};
39
40
  const timers = [];
@@ -87,11 +88,13 @@ function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = fal
87
88
  addEventListener() {}, dispatchEvent() {},
88
89
  get cookie() { return ''; }, set cookie(_v) {},
89
90
  };
90
- const location = { hostname: 'h', origin: 'https://h', href: 'https://h/' + page + search, pathname: path || '/' + page, hash: '', search, assign() {}, replace() {} };
91
+ const location = { hostname: 'h', origin: 'https://h', href: 'https://h/' + page + search, pathname: path || '/' + page, hash, search, assign() {}, replace() {} };
92
+ // `session` is the TAB's sessionStorage: pass one object to several boots and
93
+ // they are page loads in one tab (the trail, task 1004476).
91
94
  const sessionStorage = {
92
- getItem: (k) => (k in session ? session[k] : null),
93
- setItem: (k, v) => { session[k] = String(v); },
94
- removeItem: (k) => { delete session[k]; },
95
+ getItem: (k) => { if (sessionThrows) throw new Error('SecurityError'); return k in session ? session[k] : null; },
96
+ setItem: (k, v) => { if (sessionThrows) throw new Error('SecurityError'); session[k] = String(v); },
97
+ removeItem: (k) => { if (sessionThrows) throw new Error('SecurityError'); delete session[k]; },
95
98
  };
96
99
  const localStorage = {
97
100
  getItem: (k) => { if (storageThrows) throw new Error('SecurityError'); return k in store ? store[k] : null; },
@@ -131,6 +134,11 @@ function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = fal
131
134
  shell: () => windowObj.OTBShell,
132
135
  session,
133
136
  resizes: () => resizes,
137
+ // A hash route moving without a page load (a goal opened on /goals).
138
+ go(h) { location.hash = h; for (const fn of windowListeners.hashchange || []) fn({ type: 'hashchange' }); },
139
+ trail: () => JSON.parse(session['hall-nav-trail'] || '[]').map((c) => c.label),
140
+ crumbs: () => [...topbar._html.matchAll(/class="topbar-crumb" href="([^"]*)">([^<]*)</g)].map((m) => `${m[2]} ${m[1]}`),
141
+ back: () => { const m = /id="topbar-back" href="([^"]*)"[^>]*>(?:<svg[\s\S]*?<\/svg>)?<span class="topbar-back__label">([^<]*)</.exec(topbar._html); return m ? `${m[2]} ${m[1]}` : null; },
134
142
  // Press a key the way the browser dispatches it: every window listener,
135
143
  // then the page may mark the press used, then the queued timers run.
136
144
  press(key, { target = { tagName: 'CANVAS' }, pageUses = false } = {}) {
@@ -277,6 +285,115 @@ test('Back: an origin\'s own ?from= is dropped, so one page\'s way back never ch
277
285
  assert.match(p.topbar(), /href="\/roster\?q=a"/);
278
286
  });
279
287
 
288
+ // THE TRAIL (task 1004476). The owner's case: Goals -> goal 12 -> task 340 ->
289
+ // an idea shows "Goals › goal 12 › task 340", each a link, and Back is the last.
290
+ // One `session` object across boots is one browser tab.
291
+ function walkToIdea() {
292
+ const tab = {};
293
+ const goals = boot({ page: 'goals', optIn: false, path: '/goals', session: Object.assign(tab, { 'hall-nav-hop': '/goals' }) });
294
+ goals.go('#/goal/12');
295
+ boot({ page: 'task', optIn: false, path: '/task/340', search: '?from=' + encodeURIComponent('/goals#/goal/12'), fallback: '/work', session: tab });
296
+ const idea = boot({ page: 'idea', optIn: false, path: '/idea/4', referrer: 'https://h/task/340', fallback: '/thinking', session: tab });
297
+ return { tab, idea };
298
+ }
299
+
300
+ test('trail: a drill-down two layers deep shows every step, each a link, and Back is the last', () => {
301
+ const { tab, idea } = walkToIdea();
302
+ assert.deepEqual(idea.crumbs(), ['Goals /goals', 'goal 12 /goals#/goal/12']);
303
+ assert.equal(idea.back(), 'task 340 /task/340', 'the step before this page is still the Back link');
304
+ assert.match(idea.topbar(), /<nav class="topbar-trail topbar-trail--long" aria-label="Breadcrumb">/);
305
+ assert.deepEqual(JSON.parse(tab['hall-nav-trail']).map((c) => c.label), ['Goals', 'goal 12', 'task 340', 'idea 4']);
306
+ });
307
+
308
+ test('trail: one step back is exactly the Back link it always was', () => {
309
+ const tab = {};
310
+ boot({ page: 'roster', optIn: false, path: '/roster', session: Object.assign(tab, { 'hall-nav-hop': '/roster' }) });
311
+ const p = boot({ page: 'profile', optIn: false, search: '?id=7', referrer: 'https://h/roster', session: tab });
312
+ assert.doesNotMatch(p.topbar(), /topbar-trail|topbar-crumb/);
313
+ assert.match(p.topbar(), /<a class="topbar-back" id="topbar-back" href="\/roster" title="Back to Builders"><svg/);
314
+ });
315
+
316
+ test('trail: returning to a crumb trims everything after it, and a reload keeps it', () => {
317
+ const { tab } = walkToIdea();
318
+ const goal = boot({ page: 'goals', optIn: false, path: '/goals', hash: '#/goal/12', referrer: 'https://h/idea/4', session: tab });
319
+ assert.deepEqual(goal.crumbs(), [], 'one step left, so just Back');
320
+ assert.equal(goal.back(), 'Goals /goals');
321
+ assert.deepEqual(goal.trail(), ['Goals', 'goal 12']);
322
+ // A reload keeps its referrer: the task opened from the goal again.
323
+ const task = boot({ page: 'task', optIn: false, path: '/task/340', referrer: 'https://h/goals', fallback: '/work', session: tab });
324
+ const again = boot({ page: 'task', optIn: false, path: '/task/340', referrer: 'https://h/goals', fallback: '/work', session: tab });
325
+ for (const p of [task, again]) {
326
+ assert.deepEqual(p.crumbs(), ['Goals /goals'], 'a referrer has no #hash, and still finds goal 12');
327
+ assert.equal(p.back(), 'goal 12 /goals#/goal/12');
328
+ }
329
+ assert.deepEqual(again.trail(), ['Goals', 'goal 12', 'task 340']);
330
+ });
331
+
332
+ test('trail: a hash route back to a crumb trims, and a view switch only renames the page', () => {
333
+ const tab = {};
334
+ const goals = boot({ page: 'goals', optIn: false, path: '/goals', session: Object.assign(tab, { 'hall-nav-hop': '/goals' }) });
335
+ goals.go('#/goal/12');
336
+ assert.deepEqual(goals.trail(), ['Goals', 'goal 12']);
337
+ goals.go('');
338
+ assert.deepEqual(goals.trail(), ['Goals'], 'back to the list is a return, not a step');
339
+ goals.go('#archived');
340
+ assert.deepEqual(goals.trail(), ['Goals'], 'a view of the same page is not a drill-down');
341
+ });
342
+
343
+ test('trail: a sidebar hop starts over', () => {
344
+ const { tab } = walkToIdea();
345
+ const work = boot({ page: 'work', optIn: false, path: '/work', referrer: 'https://h/idea/4', session: Object.assign(tab, { 'hall-nav-hop': '/work' }) });
346
+ assert.doesNotMatch(work.topbar(), /topbar-back/);
347
+ assert.deepEqual(work.trail(), ['Tasks']);
348
+ const task = boot({ page: 'task', optIn: false, path: '/task/9', referrer: 'https://h/work', fallback: '/work', session: tab });
349
+ assert.deepEqual(task.crumbs(), []);
350
+ assert.equal(task.back(), 'Tasks /work');
351
+ });
352
+
353
+ test('trail: an origin the trail does not know starts a fresh one by the ?from= rules', () => {
354
+ const { tab } = walkToIdea();
355
+ const p = boot({ page: 'profile', optIn: false, search: '?id=7&from=%2Froster', referrer: 'https://h/idea/4', session: tab });
356
+ assert.deepEqual(p.crumbs(), [], 'the old trail is not this page\'s way here');
357
+ assert.equal(p.back(), 'Builders /roster');
358
+ assert.deepEqual(p.trail(), ['Builders', 'Profile']);
359
+ });
360
+
361
+ test('trail: capped at four crumbs, and on a phone the middle collapses', () => {
362
+ const tab = {};
363
+ boot({ page: 'goals', optIn: false, path: '/goals', session: Object.assign(tab, { 'hall-nav-hop': '/goals' }) });
364
+ let prev = '/goals';
365
+ let last;
366
+ for (const id of [1, 2, 3, 4, 5]) {
367
+ last = boot({ page: 'task', optIn: false, path: `/task/${id}`, referrer: `https://h${prev}`, fallback: '/work', session: tab });
368
+ prev = `/task/${id}`;
369
+ }
370
+ assert.deepEqual(last.crumbs(), ['task 1 /task/1', 'task 2 /task/2', 'task 3 /task/3'], 'Goals fell off the front');
371
+ assert.equal(last.back(), 'task 4 /task/4');
372
+ assert.equal(last.trail().length, 5, 'four crumbs plus this page');
373
+ const html = last.topbar();
374
+ assert.equal((html.match(/topbar-trail__step--mid/g) || []).length, 2, 'every crumb between the first and Back is the middle');
375
+ assert.match(html, /<nav class="topbar-trail topbar-trail--long"/);
376
+ assert.match(html, /<span class="topbar-trail__more"><a class="topbar-crumb topbar-crumb--more" href="\/task\/1" title="task 1 › task 2 › task 3" aria-label="task 1">…<\/a>/,
377
+ 'the fold is a link to the start of the trail, naming what it hides');
378
+ const css = read('style.css');
379
+ assert.match(css, /@media \(max-width: 640px\) \{\s*\.topbar-trail__step--mid \{ display: none; \}\s*\.topbar-trail--long \.topbar-trail__more \{ display: inline-flex; \}/,
380
+ 'source: on a phone the middle hides and "…" stands in');
381
+ assert.match(css, /@media \(max-width: 480px\) \{\s*\.topbar-trail__step--first \{ display: none; \}/, 'source: narrower, the first crumb folds too');
382
+ assert.match(css, /@media \(max-width: 360px\) \{\s*\.topbar-trail__mark, \.topbar-trail \.topbar-trail__more, \.topbar-trail \.topbar-back__label \{ display: none; \}/,
383
+ 'source: narrowest, only the Back arrow is left');
384
+ });
385
+
386
+ test('trail: a hostile stored crumb is dropped, and storage that throws keeps the one-step Back', () => {
387
+ const tab = { 'hall-nav-trail': JSON.stringify([{ href: '//evil.example', label: 'x' }, { href: '/api/gds/x', label: 'y' },
388
+ { href: 'javascript:alert(1)', label: 'z' }, { href: '/goals', label: '<b>Goals</b>' }]) };
389
+ const p = boot({ page: 'task', optIn: false, path: '/task/9', referrer: 'https://h/goals', fallback: '/work', session: tab });
390
+ assert.equal(p.back(), '&lt;b&gt;Goals&lt;/b&gt; /goals', 'a stored label is escaped');
391
+ assert.deepEqual(JSON.parse(tab['hall-nav-trail']).map((c) => c.href), ['/goals', '/task/9']);
392
+ const locked = boot({ page: 'task', optIn: false, path: '/task/9', referrer: 'https://h/goals', fallback: '/work', sessionThrows: true });
393
+ assert.equal(locked.back(), 'Goals /goals');
394
+ assert.deepEqual(locked.crumbs(), []);
395
+ });
396
+
280
397
  test('an opted-in page renders ONE switch in the top bar, off by default', () => {
281
398
  const p = boot();
282
399
  const btn = p.button();
@@ -635,7 +635,7 @@ try {
635
635
  assert.match(HTML, /<body data-page="tweak-editor"[^>]*data-fullscreen-switch/);
636
636
  assert.equal((HTML.match(/<h1\b/g) || []).length, 1);
637
637
  assert.ok(HTML.indexOf('tweak-editor-lib.js') < HTML.indexOf('tweak-editor.js?'), 'the lib loads first');
638
- assert.match(HTML, /shell\.js\?v=2026-10-02-back/);
638
+ assert.match(HTML, /shell\.js\?v=2026-10-02-trail/);
639
639
  assert.equal(/fonts\.googleapis\.com\/css/.test(HTML), false);
640
640
  for (const id of ['tw-bar', 'tw-counter', 'tw-open', 'tw-open-more', 'tw-divider', 'tw-openwrap', 'tw-open-menu', 'tw-open-tab', 'tw-open-split', 'tw-frame', 'tw-frame-view', 'tw-split-close', 'tw-download', 'tw-upload', 'tw-file', 'tw-submit', 'tw-paper', 'tw-doc', 'tw-notice', 'tw-done', 'tw-take', 'tw-pick', 'tw-picks', 'tw-refused', 'tw-refused-title', 'tw-refused-list', 'tw-refused-next', 'tw-refused-download', 'tw-refused-close']) {
641
641
  assert.ok(new RegExp(`id="${id}"`).test(HTML), `#${id} is in the markup`);
@@ -18,6 +18,7 @@
18
18
  // Run: node tests/idea_triage.mjs
19
19
 
20
20
  import { strict as assert } from 'node:assert';
21
+ import fs from 'node:fs';
21
22
  import { createRequire } from 'node:module';
22
23
 
23
24
  const require = createRequire(import.meta.url);
@@ -162,5 +163,44 @@ await test('listOpenIdeas selects the triage-provenance columns', async () => {
162
163
  assert.match(select.sql, /triage_note/);
163
164
  });
164
165
 
166
+ // ── task 1003623: batch-cluster follow-ups ─────────────────────────────────
167
+ const triage = require('../scripts/gds/triage.js');
168
+ const fakeRl = (answers) => {
169
+ const asked = [];
170
+ return { asked, question: (q, cb) => { asked.push(q); cb(answers.shift() ?? 's'); } };
171
+ };
172
+ const pair = [
173
+ { id: 1, title: 'a', kind: 'bug' },
174
+ { id: 2, title: 'b', kind: 'bug' },
175
+ ];
176
+ const freshState = () => ({ summary: { promoted: 0, discarded: 0, merged: 0, skipped: 0 }, discardedIds: [], mergedIds: [] });
177
+
178
+ await test('cluster verdict accepts the natural "keep #N" phrasing', async () => {
179
+ // A one-member cluster keeps the verdict free of API writes: nothing to merge.
180
+ const rl = fakeRl(['keep #1', 's']);
181
+ const state = freshState();
182
+ await triage.handleCluster(rl, [pair[0]], new Set([1]), state);
183
+ assert.equal(rl.asked.length, 1, 'keep #1 was understood, not re-prompted as unknown');
184
+ assert.equal(state.summary.skipped, 0);
185
+ });
186
+
187
+ await test('skipping a cluster counts every member as skipped', async () => {
188
+ const rl = fakeRl(['s']);
189
+ const state = freshState();
190
+ await triage.handleCluster(rl, pair, new Set([1, 2]), state);
191
+ assert.equal(state.summary.skipped, 2);
192
+ });
193
+
194
+ await test('spark-grade ideas are not eligible for batch clustering', async () => {
195
+ const mk = (id, grade) => ({ id, grade, kind: 'idea', title: 'dashboard filter export widget', body_md: 'dashboard filter export widget', captured_at: '2026-01-01' });
196
+ assert.equal(triage.clusterIdeas([mk(1, 'spark'), mk(2, 'spark')]).clusters.length, 0);
197
+ assert.equal(triage.clusterIdeas([mk(1, 'quick'), mk(2, 'quick')]).clusters.length, 1);
198
+ });
199
+
200
+ await test('SKILL.md has no stale bare "Step 1.5" cross-references', async () => {
201
+ const md = fs.readFileSync(new URL('../.claude/skills/idea-triage/SKILL.md', import.meta.url), 'utf8');
202
+ assert.ok(!/Step 1\.5(?![ab])/.test(md));
203
+ });
204
+
165
205
  console.log(`\nIdea triage tests: ${passed} passed, ${failed} failed`);
166
206
  process.exit(failed === 0 ? 0 : 1);
@@ -87,7 +87,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
87
87
  // the file and left the query alone, so a returning browser kept the cached copy —
88
88
  // a fix that reaches nobody looks identical to a fix that does not work.
89
89
 
90
- const SHELL_SHA = 'e5094c169d5ee6ef';
90
+ const SHELL_SHA = '901fc3252b096ce6';
91
91
 
92
92
  test('shell.js has not changed without its cache-bust being bumped', () => {
93
93
  const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);