@bongos/core 1.19.870 → 1.19.872

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 (102) hide show
  1. package/.bongos-core.json +164 -94
  2. package/.claude/skills/merge-mode/SKILL.md +1 -1
  3. package/CONTRIBUTING.md +1 -1
  4. package/README.md +1 -1
  5. package/clients/bongos-client/README.md +1 -1
  6. package/clients/bongos-client/bongos-client.global.js +4 -0
  7. package/clients/bongos-client/index.cjs +4 -0
  8. package/clients/bongos-client/index.d.ts +5 -0
  9. package/clients/bongos-client/index.mjs +4 -0
  10. package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +10 -0
  11. package/docs/api/openapi.json +61 -3
  12. package/docs/api-reference.md +7 -1
  13. package/docs/architecture.md +7 -7
  14. package/docs/branding-contract.md +1 -1
  15. package/docs/copy-inventory.md +190 -189
  16. package/docs/copy-registry.json +264 -255
  17. package/docs/design/hall-direction-v2.md +2 -2
  18. package/docs/module-api-changelog.md +4 -0
  19. package/docs/onboarding/diagrams/README.md +4 -4
  20. package/docs/onboarding/drachmae-and-karma.md +2 -2
  21. package/docs/onboarding/primer.md +1 -1
  22. package/docs/recipes/README.md +1 -1
  23. package/docs/recipes/bongos-cli-release.md +4 -4
  24. package/docs/recipes/core-release-pipeline.md +3 -3
  25. package/docs/recipes/instance-deploy.md +2 -2
  26. package/docs/recipes/ops-gotchas.md +2 -2
  27. package/docs/recipes/search-index-ingestion.md +2 -2
  28. package/docs/recipes/upgrading-the-core.md +1 -1
  29. package/modules/autonomy/gauge.js +218 -0
  30. package/modules/dev-box/app/README.md +1 -1
  31. package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
  32. package/modules/hall-ui/public/blockers.js +138 -34
  33. package/modules/hall-ui/public/builders.js +2 -1
  34. package/modules/hall-ui/public/collab.html +2 -2
  35. package/modules/hall-ui/public/collab.js +6 -3
  36. package/modules/hall-ui/public/dom-utils.js +46 -5
  37. package/modules/hall-ui/public/goals-page.js +7 -3
  38. package/modules/hall-ui/public/goals-render.js +4 -3
  39. package/modules/hall-ui/public/hall-render.js +1 -1
  40. package/modules/hall-ui/public/people.js +3 -3
  41. package/modules/hall-ui/public/profile-charts.js +5 -2
  42. package/modules/hall-ui/public/profile.html +3 -3
  43. package/modules/hall-ui/public/profile.js +6 -6
  44. package/modules/hall-ui/public/settings-specialities.js +2 -2
  45. package/modules/hall-ui/public/settings.html +4 -4
  46. package/modules/hall-ui/public/settings.js +2 -2
  47. package/modules/hall-ui/public/shell.js +70 -0
  48. package/modules/hall-ui/public/style.css +31 -0
  49. package/modules/hall-ui/public/work.js +12 -14
  50. package/modules/provisioning/module.json +3 -3
  51. package/modules/public-landing/public/terms.html +1 -1
  52. package/package-lock.json +2 -2
  53. package/package.json +1 -1
  54. package/scripts/gds/autobongos-loop.js +170 -0
  55. package/scripts/gds/autobongos-run.js +317 -0
  56. package/scripts/gds/autobongos-verify.js +377 -0
  57. package/scripts/gds/autonomy-gauge-statusline.js +124 -0
  58. package/scripts/gds/autonomy-gauge.js +84 -0
  59. package/scripts/gds/run-unit-tests.js +120 -2
  60. package/scripts/gds/sequence.js +3 -0
  61. package/scripts/gds/start.js +16 -10
  62. package/src/bongos/core-update.js +147 -0
  63. package/src/bongos/routes/core-update.js +49 -0
  64. package/src/bongos/routes.js +2 -0
  65. package/src/module-api.js +1 -1
  66. package/tests/account_privacy_flags.mjs +3 -1
  67. package/tests/autobongos_loop.mjs +446 -0
  68. package/tests/autobongos_verify.mjs +325 -0
  69. package/tests/autonomy_gauge.mjs +344 -0
  70. package/tests/blockers_hall.mjs +20 -6
  71. package/tests/blockers_kit_pager.mjs +260 -0
  72. package/tests/blockers_page_ui.mjs +5 -4
  73. package/tests/box_ship_permissions.mjs +30 -0
  74. package/tests/core_update_banner.mjs +308 -0
  75. package/tests/eviction_policy.mjs +22 -1
  76. package/tests/git_guard.mjs +68 -0
  77. package/tests/goal_criterion_confirm_ui.mjs +7 -0
  78. package/tests/goal_invite_consent.mjs +7 -0
  79. package/tests/goal_sort_staleness.mjs +6 -2
  80. package/tests/goal_subtitle.mjs +48 -4
  81. package/tests/hall_board_world.mjs +4 -1
  82. package/tests/hall_builders_page_pass.mjs +7 -0
  83. package/tests/hall_landing_boot.mjs +7 -0
  84. package/tests/hall_profile_world.mjs +20 -2
  85. package/tests/hall_record_world.mjs +7 -0
  86. package/tests/hall_roster_world.mjs +2 -1
  87. package/tests/hall_studio_world.mjs +7 -0
  88. package/tests/hook_shell_tools.mjs +23 -1
  89. package/tests/idea_detail_page.mjs +7 -0
  90. package/tests/idea_spark_hall.mjs +7 -0
  91. package/tests/integration_parked_ratchet.mjs +79 -0
  92. package/tests/module_contributions.mjs +134 -8
  93. package/tests/people_directory_clip.mjs +7 -0
  94. package/tests/profile_activity.mjs +7 -0
  95. package/tests/profile_ui_cross_project.mjs +9 -1
  96. package/tests/provision_disconnect.mjs +7 -0
  97. package/tests/provision_restart.mjs +7 -0
  98. package/tests/provision_settings_apply.mjs +7 -0
  99. package/tests/sessions_kit_pager.mjs +7 -0
  100. package/tests/watch_applications_queue.mjs +7 -0
  101. package/tests/watch_roster_self_row.mjs +7 -0
  102. package/tests/watch_sealed_floor.mjs +7 -0
@@ -0,0 +1,308 @@
1
+ // tests/core_update_banner.mjs — the core-update banner, both halves (task 1004045).
2
+ //
3
+ // The 1003159 grade's lesson is the design constraint here: that task shipped two
4
+ // guards that could never refuse, past a green suite, because every assertion read
5
+ // SOURCE TEXT. So these EXECUTE — the verdict function against real inputs, the
6
+ // registry read against a fake transport, and the banner against the real shell.js
7
+ // in a DOM stub — and the one unavoidable source assertion is labelled as such.
8
+ import assert from 'node:assert/strict';
9
+ import { test } from 'node:test';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import vm from 'node:vm';
13
+ import { createRequire } from 'node:module';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const require_ = createRequire(import.meta.url);
18
+ const CU = require_(path.join(ROOT, 'src/bongos/core-update.js'));
19
+
20
+ // The module exports exactly one function, so these helpers drive the real thing
21
+ // through it rather than reaching past it into internals that no route uses.
22
+ // `store` is per-call so the unit assertions never share the module's own cache.
23
+ const freshStore = () => ({ at: 0, versions: [], ok: false, inflight: null });
24
+ const verdictFor = (running, available, channel) =>
25
+ CU.readCoreUpdate({ running, channel, store: freshStore(), fetch: async () => ({ ok: true, versions: available }) });
26
+ const SHELL = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/shell.js'), 'utf8');
27
+
28
+ // ── the verdict ────────────────────────────────────────────────────────────────
29
+
30
+ test('behind: names the newest version the channel permits', async () => {
31
+ const v = await verdictFor('1.19.861', ['1.19.860', '1.19.861', '1.19.871']);
32
+ assert.equal(v.behind, true);
33
+ assert.equal(v.latest, '1.19.871');
34
+ assert.equal(v.reading, 'ok');
35
+ });
36
+
37
+ test('current: not behind, and no version offered', async () => {
38
+ const v = await verdictFor('1.19.871', ['1.19.861', '1.19.871']);
39
+ assert.equal(v.behind, false);
40
+ assert.equal(v.latest, null);
41
+ assert.equal(v.reading, 'ok');
42
+ });
43
+
44
+ test('a MAJOR ahead is not an upgrade this banner offers', async () => {
45
+ // The runner refuses a major unattended, so offering one would be a button
46
+ // whose press is then refused — previewFromSnapshot avoids the same trap.
47
+ const v = await verdictFor('1.19.861', ['2.0.0']);
48
+ assert.equal(v.behind, false);
49
+ });
50
+
51
+ test('a prerelease ahead is not offered', async () => {
52
+ const v = await verdictFor('1.19.861', ['1.19.872-rc.1']);
53
+ assert.equal(v.behind, false);
54
+ });
55
+
56
+ test('NO ANSWER is not "up to date" — the distinction the banner turns on', async () => {
57
+ // A registry that cannot be reached, and a box that cannot name its own core:
58
+ // two different roads to the same must-say-nothing state.
59
+ const unreachable = await CU.readCoreUpdate({ running: '1.19.861', store: freshStore(), fetch: async () => ({ ok: false, versions: [] }) });
60
+ assert.equal(unreachable.reading, 'no_answer');
61
+ assert.equal(unreachable.behind, false);
62
+ const noVersion = await verdictFor(null, ['1.19.871']);
63
+ assert.equal(noVersion.reading, 'no_answer');
64
+ });
65
+
66
+ // ── the registry read: fail-soft, never a throw ───────────────────────────────
67
+
68
+ function fakeGet(handler) {
69
+ return (_url, _opts, cb) => {
70
+ const listeners = {};
71
+ const res = {
72
+ statusCode: 200, resume() {}, setEncoding() {},
73
+ on(ev, fn) { (listeners[ev] ||= []).push(fn); return res; },
74
+ };
75
+ handler(res);
76
+ queueMicrotask(() => {
77
+ cb(res);
78
+ for (const fn of listeners.data || []) fn(res._body || '');
79
+ for (const fn of listeners.end || []) fn();
80
+ });
81
+ return { on() {}, destroy() {} };
82
+ };
83
+ }
84
+
85
+ test('a non-200 from the registry yields no answer and does not throw', async () => {
86
+ const v = await CU.readCoreUpdate({ running: '1.19.861', store: freshStore(), get: fakeGet((res) => { res.statusCode = 503; }) });
87
+ assert.equal(v.reading, 'no_answer');
88
+ });
89
+
90
+ test('an unparseable packument yields no answer and does not throw', async () => {
91
+ const v = await CU.readCoreUpdate({ running: '1.19.861', store: freshStore(), get: fakeGet((res) => { res._body = 'not json'; }) });
92
+ assert.equal(v.reading, 'no_answer');
93
+ });
94
+
95
+ test('a real packument is parsed, and its newest version reaches the verdict', async () => {
96
+ // Drives the ACTUAL https parsing path, only the socket is fake.
97
+ const v = await CU.readCoreUpdate({
98
+ running: '1.19.861', store: freshStore(),
99
+ get: fakeGet((res) => { res._body = JSON.stringify({ versions: { '1.19.861': {}, '1.19.871': {} } }); }),
100
+ });
101
+ assert.equal(v.reading, 'ok');
102
+ assert.equal(v.latest, '1.19.871');
103
+ });
104
+
105
+ test('the cache spares the registry a second read inside the TTL', async () => {
106
+ const store = freshStore();
107
+ let calls = 0;
108
+ const fetch = async () => { calls++; return { ok: true, versions: ['1.19.871'] }; };
109
+ let t = 1_000_000;
110
+ const now = () => t;
111
+ const args = { running: '1.19.861', store, fetch, now, ttlMs: 1000 };
112
+ await CU.readCoreUpdate(args);
113
+ await CU.readCoreUpdate(args);
114
+ assert.equal(calls, 1, 'second call served from cache');
115
+ t += 2000;
116
+ await CU.readCoreUpdate(args);
117
+ assert.equal(calls, 2, 'a call past the TTL re-reads');
118
+ });
119
+
120
+ test('concurrent cold-cache reads make ONE outbound call, not one each', async () => {
121
+ const store = freshStore();
122
+ let calls = 0;
123
+ const fetch = () => { calls++; return new Promise((r) => setTimeout(() => r({ ok: true, versions: ['1.19.871'] }), 5)); };
124
+ const args = { running: '1.19.861', store, fetch, now: () => 1 };
125
+ const all = await Promise.all([CU.readCoreUpdate(args), CU.readCoreUpdate(args), CU.readCoreUpdate(args)]);
126
+ assert.equal(calls, 1, 'three simultaneous cold reads share one registry call');
127
+ for (const v of all) assert.equal(v.latest, '1.19.871');
128
+ });
129
+
130
+ test('a failed read is NOT cached as an answer', async () => {
131
+ const store = freshStore();
132
+ let calls = 0;
133
+ const fetch = async () => { calls++; return { ok: false, versions: [] }; };
134
+ await CU.readCoreUpdate({ running: '1.19.861', store, fetch, now: () => 5 });
135
+ await CU.readCoreUpdate({ running: '1.19.861', store, fetch, now: () => 6 });
136
+ assert.equal(calls, 2, 'a failure must be retried, not remembered');
137
+ });
138
+
139
+ // ── the route's gate ──────────────────────────────────────────────────────────
140
+
141
+ test('the route is gated by TWO middlewares before its handler', () => {
142
+ const router = require_(path.join(ROOT, 'src/bongos/routes/core-update.js'))();
143
+ const layer = router.stack.find((l) => l.route && l.route.path === '/core-update');
144
+ assert.ok(layer, 'GET /core-update is registered');
145
+ assert.equal(layer.route.stack.length, 3, 'requireBuilder + requirePermission + handler');
146
+ });
147
+
148
+ test('[source] the gate names core.pin.move, the deploy door’s own atom', () => {
149
+ // The one source-text assertion, and labelled: the middleware above is an opaque
150
+ // closure, so the ATOM it was built with cannot be read back off the stack. It
151
+ // guards against the gate being silently widened, not against it being absent —
152
+ // the layer count above is what proves a gate is there at all.
153
+ const src = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/core-update.js'), 'utf8');
154
+ assert.match(src, /requirePermission\('core\.pin\.move'\)/);
155
+ });
156
+
157
+ // ── the route, driven as a REAL request ───────────────────────────────────────
158
+ //
159
+ // The grader's finding on the first round of this task, and it was right: every
160
+ // route assertion above inspects the stack or the source, and NEITHER can see a
161
+ // mis-shaped `asyncHandler` call — which is precisely the bug that shipped
162
+ // (handler passed where the tag goes, so every request 500s). This drives an
163
+ // actual HTTP request through the actual assembled router, with only the two auth
164
+ // gates replaced, so the handler wiring is exercised end to end.
165
+
166
+ test('a real request through the assembled router returns the verdict, not a 500', async () => {
167
+ // The route captures `auth` at module load, so the stubs must be installed in
168
+ // the require cache BEFORE it is first required — hence the fresh child require
169
+ // and the explicit cache delete.
170
+ const r2 = createRequire(import.meta.url);
171
+ const authPath = r2.resolve(path.join(ROOT, 'src/bongos/auth.js'));
172
+ const routePath = r2.resolve(path.join(ROOT, 'src/bongos/routes/core-update.js'));
173
+ const realAuth = r2(authPath);
174
+ const savedBuilder = realAuth.requireBuilder;
175
+ const savedPerm = realAuth.requirePermission;
176
+ realAuth.requireBuilder = (_req, _res, next) => next();
177
+ realAuth.requirePermission = () => (_req, _res, next) => next();
178
+ delete r2.cache[routePath];
179
+
180
+ // Warm the module's OWN cache through the public door, so the route's own
181
+ // dependency-free call is served from it and the test never touches the network.
182
+ await CU.readCoreUpdate({ running: '1.19.861', fetch: async () => ({ ok: true, versions: ['1.19.861', '1.19.871'] }) });
183
+ const express = r2('express');
184
+ const app = express();
185
+ app.use(r2(routePath)());
186
+ const server = app.listen(0);
187
+ await new Promise((res) => server.once('listening', res));
188
+ try {
189
+ const res = await fetch(`http://127.0.0.1:${server.address().port}/core-update`);
190
+ assert.equal(res.status, 200, 'the handler runs — a 500 here is the asyncHandler arity bug');
191
+ const body = await res.json();
192
+ assert.equal(body.ok, true);
193
+ assert.equal(body.update.reading, 'ok');
194
+ assert.ok('behind' in body.update, 'the verdict shape reaches the client');
195
+ } finally {
196
+ server.close();
197
+ realAuth.requireBuilder = savedBuilder;
198
+ realAuth.requirePermission = savedPerm;
199
+ delete r2.cache[routePath];
200
+ }
201
+ });
202
+
203
+ // ── the banner ────────────────────────────────────────────────────────────────
204
+
205
+ function boot({ permissions, update, status = 200, dismissed = null, provisioning = true } = {}) {
206
+ const els = new Map();
207
+ const inserted = [];
208
+ const stored = {};
209
+ const requested = [];
210
+ const makeEl = () => ({
211
+ dataset: {}, style: {}, textContent: '', hidden: false, _html: '', _children: [],
212
+ classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
213
+ setAttribute() {}, getAttribute() { return null; }, removeAttribute() {},
214
+ addEventListener(ev, fn) { (this._on ||= {})[ev] = fn; },
215
+ querySelector() { return { addEventListener: (e, f) => { this._close = f; } }; },
216
+ querySelectorAll() { return []; },
217
+ appendChild() {}, focus() {}, click() {}, scrollIntoView() {}, remove() { this._removed = true; },
218
+ insertAdjacentElement(_pos, el) { inserted.push(el); },
219
+ set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
220
+ });
221
+ const documentObj = {
222
+ body: { dataset: { page: 'settings' } },
223
+ documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } } },
224
+ getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
225
+ querySelectorAll() { return []; },
226
+ createElement() { return makeEl(); },
227
+ addEventListener() {}, dispatchEvent() {},
228
+ get cookie() { return ''; }, set cookie(_v) {},
229
+ };
230
+ const location = { hostname: 'h', origin: 'https://h', pathname: '/settings', hash: '', assign() {}, replace() {} };
231
+ const windowObj = {
232
+ __BRANDING__: {}, __MODULES__: { enabled: { provisioning } },
233
+ BongosClient: {
234
+ createClient: () => ({
235
+ request: (method, p) => {
236
+ requested.push(method + ' ' + p);
237
+ if (p === '/api/bongos/core-update') return Promise.resolve({ status, data: update ? { ok: true, update } : null });
238
+ return new Promise(() => {});
239
+ },
240
+ }),
241
+ },
242
+ matchMedia: () => ({ matches: false, addEventListener() {} }),
243
+ localStorage: {
244
+ getItem: (k) => (k === 'bongos.coreUpdate.dismissed' ? dismissed : null),
245
+ setItem: (k, v) => { stored[k] = v; },
246
+ },
247
+ requestAnimationFrame() {}, scrollTo() {}, location, addEventListener() {},
248
+ };
249
+ const sandbox = { window: windowObj, document: documentObj, location, console, setTimeout, clearTimeout, queueMicrotask,
250
+ CustomEvent: class { constructor(t, i) { this.type = t; Object.assign(this, i); } } };
251
+ sandbox.globalThis = sandbox;
252
+ vm.createContext(sandbox);
253
+ vm.runInContext(SHELL, sandbox, { filename: 'shell.js' });
254
+ windowObj.OTBShell.applyAccess({ authed: true, rank: 'archon', permissions });
255
+ return { inserted, stored, requested, banner: () => inserted.find((e) => String(e._html).includes('is available')) };
256
+ }
257
+
258
+ const BEHIND = { running: '1.19.861', latest: '1.19.871', behind: true, reading: 'ok' };
259
+
260
+ test('an owner who is behind sees the banner, naming both versions', async () => {
261
+ const b = boot({ permissions: { 'core.pin.move': true }, update: BEHIND });
262
+ await new Promise((r) => setTimeout(r, 0));
263
+ const el = b.banner();
264
+ assert.ok(el, 'banner rendered');
265
+ assert.match(el.innerHTML, /1\.19\.871/);
266
+ assert.match(el.innerHTML, /1\.19\.861/);
267
+ });
268
+
269
+ test('a builder without the atom is never even asked about it', async () => {
270
+ const b = boot({ permissions: { 'task.create': true }, update: BEHIND });
271
+ await new Promise((r) => setTimeout(r, 0));
272
+ assert.equal(b.banner(), undefined);
273
+ assert.ok(!b.requested.includes('GET /api/bongos/core-update'), 'no request is made');
274
+ });
275
+
276
+ test('no answer renders no banner — not a false "up to date"', async () => {
277
+ const b = boot({ permissions: { 'core.pin.move': true }, update: { running: '1.19.861', latest: null, behind: false, reading: 'no_answer' } });
278
+ await new Promise((r) => setTimeout(r, 0));
279
+ assert.equal(b.banner(), undefined);
280
+ });
281
+
282
+ test('a dismissal silences THAT version only', async () => {
283
+ const same = boot({ permissions: { 'core.pin.move': true }, update: BEHIND, dismissed: '1.19.871' });
284
+ await new Promise((r) => setTimeout(r, 0));
285
+ assert.equal(same.banner(), undefined, 'the dismissed version stays dismissed');
286
+
287
+ const newer = boot({ permissions: { 'core.pin.move': true }, update: BEHIND, dismissed: '1.19.800' });
288
+ await new Promise((r) => setTimeout(r, 0));
289
+ assert.ok(newer.banner(), 'a newer release re-announces itself');
290
+ });
291
+
292
+ test('the Upgrade action appears only where the deploy door exists', async () => {
293
+ const on = boot({ permissions: { 'core.pin.move': true }, update: BEHIND, provisioning: true });
294
+ await new Promise((r) => setTimeout(r, 0));
295
+ assert.match(on.banner().innerHTML, /href="\/deploy"/);
296
+
297
+ const off = boot({ permissions: { 'core.pin.move': true }, update: BEHIND, provisioning: false });
298
+ await new Promise((r) => setTimeout(r, 0));
299
+ assert.ok(off.banner(), 'a tenant still hears the news');
300
+ assert.doesNotMatch(off.banner().innerHTML, /href="\/deploy"/, 'but is not offered a 404');
301
+ });
302
+
303
+ test('the banner is asked for ONCE, not on every access settle', async () => {
304
+ const b = boot({ permissions: { 'core.pin.move': true }, update: BEHIND });
305
+ await new Promise((r) => setTimeout(r, 0));
306
+ const first = b.requested.filter((r) => r.endsWith('/core-update')).length;
307
+ assert.equal(first, 1);
308
+ });
@@ -6,7 +6,7 @@ import { strict as assert } from 'node:assert';
6
6
  import { createRequire } from 'node:module';
7
7
  import { requireHostModule } from './helpers.mjs';
8
8
  const require = createRequire(import.meta.url);
9
- const { classifyEviction, estTokens, blockText, stubFor, normalize, readRange, isVolatileBash, salientTokens } = requireHostModule(require, '../.claude/hooks/eviction-policy.js');
9
+ const { classifyEviction, estTokens, blockText, stubFor, normalize, readRange, isVolatileBash, salientTokens, DEFAULTS } = requireHostModule(require, '../.claude/hooks/eviction-policy.js');
10
10
 
11
11
  let passed = 0, failed = 0;
12
12
  function test(name, fn) {
@@ -276,5 +276,26 @@ test('1364 unit: readRange + salientTokens edges', () => {
276
276
  assert.ok(!toks.has('plain'), 'a common short word is not distinctive');
277
277
  });
278
278
 
279
+ // The claim lifecycle card is a neverEvictPattern, and its header is built from
280
+ // branding (modules/lifecycle/ship-card.js renders productName + " · task #" + id).
281
+ // The guard used to hardcode one instance name, so it silently stopped protecting
282
+ // the card the moment the brand changed (task 1001784). Pin the BRAND-AGNOSTIC
283
+ // shape instead: any product name, and a DIGIT after the hash -- a pattern that
284
+ // loses the \d escape matches the literal letter d and so matches nothing real.
285
+ // The three positive cases are documentary: the guard reads only the " · task #<digit>"
286
+ // suffix, never the name, so they differ in SHAPE (one word, two words, a lowercase
287
+ // interior word) to show a reader that the name is not part of the match.
288
+ test('1001784: the lifecycle-card guard matches any brand, not one hardcoded name', () => {
289
+ const guarded = (text) => DEFAULTS.neverEvictPatterns.some((re) => re.test(text));
290
+ assert.ok(guarded(' Cloud Bongos · task #1001784 Claimed'), 'this instance');
291
+ assert.ok(guarded(' Acme Corp · task #42 Shipped'), 'any other brand');
292
+ assert.ok(guarded(' Port of Call · task #7 Claimed'), 'a multi-word brand with a lowercase interior word');
293
+ // The negative case must carry the same " · task #" prefix as a real card,
294
+ // or it fails for the wrong reason and passes under BOTH the buggy and the fixed
295
+ // regex. This string matches / · task #d/ and must NOT match / · task #\d/,
296
+ // which is exactly the escape this test exists to pin.
297
+ assert.ok(!guarded(' Cloud Bongos · task #d Claimed'), 'a literal d is not a task number');
298
+ });
299
+
279
300
  console.log(`\neviction_policy: ${passed} passed, ${failed} failed`);
280
301
  process.exit(failed ? 1 : 0);
@@ -118,6 +118,74 @@ test('the PowerShell tool keeps the same false-positive floor as Bash', () => {
118
118
  }
119
119
  });
120
120
 
121
+ // The four spellings PowerShell resolves to the same executable as a plain `git`
122
+ // (task 1004028). It looks a command up case-insensitively, appends the extensions
123
+ // in $env:PATHEXT, and eats a backtick as its escape character — so each of these
124
+ // RUNS on a Windows laptop, and each one used to return block:false because the
125
+ // guard matched a literal lower-case `git`.
126
+ test('PowerShell spellings of the executable are blocked, not just the lower-case one', () => {
127
+ for (const cmd of [
128
+ 'Git stash',
129
+ 'GIT branch -D task-1',
130
+ 'git.exe worktree remove ../wt',
131
+ 'GIT.EXE push origin main',
132
+ 'g`it push origin main',
133
+ 'Git.exe -C ../wt stash',
134
+ ]) {
135
+ const r = pwsh(cmd);
136
+ assert.equal(r.status, 2, `PowerShell spelling went unguarded: ${cmd}`);
137
+ assert.match(r.stderr, /git-guard: blocked/);
138
+ }
139
+ });
140
+
141
+ // Git Bash is the `Bash` tool name on a case-insensitive Windows filesystem, where
142
+ // `Git stash` and `git.exe stash` both resolve and run. The executable widening is
143
+ // therefore not PowerShell-only. The backtick is: it is PowerShell's escape
144
+ // character, and in Bash it opens a command substitution instead, so stripping it
145
+ // there would invent a git invocation the shell never runs.
146
+ test('the executable widening covers Bash too, but the backtick rule does not', () => {
147
+ for (const cmd of ['Git stash', 'git.exe push origin main', 'GIT worktree remove ../wt']) {
148
+ assert.equal(bash(cmd).status, 2, `Git-Bash spelling went unguarded: ${cmd}`);
149
+ }
150
+ assert.equal(bash('g`it push origin main').status, 0, 'a backtick is not an escape in Bash');
151
+ });
152
+
153
+ // Only the executable token folds. `git Stash` is an error git itself refuses, and
154
+ // `-d` vs `-D` is the entire point of the fourth rule — folding the flags would
155
+ // block the safe delete and make the guard useless.
156
+ test('subcommands and flags stay case-sensitive', () => {
157
+ for (const cmd of [
158
+ 'Git Stash',
159
+ 'GIT STASH',
160
+ 'Git branch -d merged-branch',
161
+ 'git.exe PUSH origin main',
162
+ 'Git WORKTREE remove ../wt',
163
+ ]) {
164
+ for (const run of [bash, pwsh]) {
165
+ assert.equal(run(cmd).status, 0, `folded a subcommand or flag it must not fold: ${cmd}`);
166
+ }
167
+ }
168
+ });
169
+
170
+ // The read-only and ordinary shapes keep passing in every spelling: widening the
171
+ // executable must not cost the guard its false-positive floor.
172
+ test('the widened executable keeps the false-positive floor in every spelling', () => {
173
+ for (const cmd of [
174
+ 'Git stash list',
175
+ 'git.exe status',
176
+ 'GIT push origin task-1004028',
177
+ 'Git branch -d merged',
178
+ 'git.exe worktree list --porcelain',
179
+ 'Github Actions rerun',
180
+ 'gitk --all',
181
+ 'node scripts/gds/worktree.js remove wt-1',
182
+ ]) {
183
+ for (const run of [bash, pwsh]) {
184
+ assert.equal(run(cmd).status, 0, `false positive on: ${cmd}`);
185
+ }
186
+ }
187
+ });
188
+
121
189
  test('a non-shell tool and unknown payload shapes still fail open', () => {
122
190
  assert.equal(
123
191
  runHook(JSON.stringify({ tool_name: 'Write', tool_input: { file_path: 'x.js', content: 'git stash' } })).status,
@@ -105,6 +105,13 @@ async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.v
105
105
  const pager = (prefix) => ({ prefix, page: 1, perPage: 25 });
106
106
  const windowObj = {
107
107
  OTB: {
108
+ // task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
109
+ // the same reason categoryChip is: an undefined helper throws mid-render.
110
+ roleAxisLabel: () => 'Role',
111
+ roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
112
+ roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
113
+ roleIsUnset: (d) => !d || d === 'unclassified',
114
+ ROLE_UNSET: 'unclassified',
108
115
  escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch])),
109
116
  $, fmtNum: (n) => String(n), fmtDate: () => '', rankLabel: (r) => r,
110
117
  toast: (m) => calls.toasts.push(m),
@@ -142,6 +142,13 @@ function harness({ pendingRequests = [] } = {}) {
142
142
  scrollTo() {}, addEventListener() {},
143
143
  matchMedia: () => ({ matches: false, addEventListener() {}, addListener() {} }),
144
144
  OTB: {
145
+ // task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
146
+ // the same reason categoryChip is: an undefined helper throws mid-render.
147
+ roleAxisLabel: () => 'Role',
148
+ roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
149
+ roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
150
+ roleIsUnset: (d) => !d || d === 'unclassified',
151
+ ROLE_UNSET: 'unclassified',
145
152
  escapeHtml: (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'),
146
153
  $: (sel) => documentObj.querySelector(sel),
147
154
  fmtNum: (n) => String(n ?? ''),
@@ -115,8 +115,12 @@ await test('a filter control may mix string and {value,label} options — the ki
115
115
  const kit = read('modules/hall-ui/public/hall-kit.js');
116
116
  assert.match(kit, /function optVal\(o\) \{ return typeof o === 'string' \? o : o\.value; \}/);
117
117
  assert.match(kit, /function optLabel\(o\) \{ return typeof o === 'string' \? o : o\.label; \}/);
118
- assert.match(WORK_SRC, /id: 'f-discipline',[\s\S]{0,200}'engineer', 'artist', 'ideator', 'ui', 'unclassified'/,
119
- 'the mixed form predates this task on the very same board');
118
+ // task 1003936 moved f-discipline off the literal list and onto OTB.roleOptions(),
119
+ // so the live example of the MIXED form is now f-goal-status. The invariant under
120
+ // test is the kit's normalisation, not which control happens to exercise it.
121
+ const GOALS_SRC = read('modules/hall-ui/public/goals-page.js');
122
+ assert.match(GOALS_SRC, /id: 'f-goal-status',[\s\S]{0,120}'open', 'achieved', 'archived'/,
123
+ 'the mixed form is still live on a real control');
120
124
  });
121
125
 
122
126
  // ---- the payload, which is where the scale actually bit ---------------------
@@ -184,6 +184,27 @@ test('an unset subtitle is hidden; a set one is shown trimmed', () => {
184
184
  assert.equal(els.sub.textContent, 'the grep path');
185
185
  });
186
186
 
187
+ // goalTitleRegions — where the row's title ends and the card's begins, as an
188
+ // ANCHOR rather than a byte count (task 1004036).
189
+ //
190
+ // The assertion below and the vacuity guard further down BOTH derive the row's
191
+ // region from here, deliberately. The previous shape asserted subtitleAttr(g)
192
+ // absent from a 160-char window while the row's real region measured 766 — 21%
193
+ // of the span it claimed to check — and a negative assertion passes when it
194
+ // finds nothing, which is also what it does when it is looking in the wrong
195
+ // place. Injecting the tooltip into the row left it passing.
196
+ //
197
+ // One derivation, not two: a guard that recomputed the region itself would stay
198
+ // green while the assertion it claims to protect was narrowed back. Narrow this
199
+ // function and the guard fails, which is the only arrangement that makes the
200
+ // guard worth having.
201
+ function goalTitleRegions(js) {
202
+ const row = js.indexOf('class="gr-title"');
203
+ const card = js.indexOf('class="goal-card__title gr-title"');
204
+ assert.ok(card > row, 'card title boundary not found — re-anchor this slice to whatever now follows the row title');
205
+ return { row, card, rowRegion: js.slice(row, card) };
206
+ }
207
+
187
208
  test('on a LIST the subtitle is a visible line; on the CARD it stays a tooltip', () => {
188
209
  // task 1003206: the row/card painters moved to goals-render.js — the second half
189
210
  // of the carve goals-lib.js began — so read the family, not one filename.
@@ -197,15 +218,38 @@ test('on a LIST the subtitle is a visible line; on the CARD it stays a tooltip',
197
218
  // task 1003307 gave the ROW its line back on the owner's call: 35 of 72 goals
198
219
  // carry a subtitle, and a tooltip does not exist on a touch screen. The CARD
199
220
  // has no room for a line, so it keeps the tooltip (task 1003224's shape).
200
- const row = js.indexOf('class="gr-title"');
221
+ const { row, card, rowRegion } = goalTitleRegions(js);
201
222
  assert.ok(row > 0, 'the row title is still rendered');
202
- assert.match(js.slice(row, row + 200), /subtitleLine\(g\)/, 'the row renders the visible line');
203
- assert.doesNotMatch(js.slice(row, row + 160), /subtitleAttr\(g\)/, 'the row does not also carry the tooltip');
204
- const card = js.indexOf('class="goal-card__title gr-title"');
205
223
  assert.ok(card > 0, 'the card title is still rendered');
224
+ assert.match(rowRegion, /subtitleLine\(g\)/, 'the row renders the visible line');
225
+ assert.doesNotMatch(rowRegion, /subtitleAttr\(g\)/, 'the row does not also carry the tooltip');
206
226
  assert.match(js.slice(card, card + 160), /subtitleAttr\(g\)/, 'the card carries the tooltip');
207
227
  });
208
228
 
229
+ // ---- the negative assertion above is not vacuous (task 1004036) -------------
230
+ // A negative assertion passes when it finds nothing, which is also what it does
231
+ // when it is looking in the wrong place — so one that has never been watched
232
+ // FAIL is not evidence of anything. The previous shape checked subtitleAttr(g)
233
+ // was absent from a 160-char window while the row's real region measured 766,
234
+ // so it verified 21% of the span it claimed to; injecting the tooltip into the
235
+ // row left it passing. This case does that injection and requires the anchored
236
+ // region to catch it. It guards the ASSERTION, not the page.
237
+ test('the row/tooltip check CATCHES a row that carries the tooltip', () => {
238
+ const js = goalsPageSource();
239
+ const { row, card } = goalTitleRegions(js);
240
+
241
+ // Inject the forbidden attribute INSIDE the row's region but past the old
242
+ // 160-char window — the exact regression the assertion exists to catch.
243
+ const at = row + Math.floor((card - row) / 2);
244
+ const broken = js.slice(0, at) + ' title="${subtitleAttr(g)}" ' + js.slice(at);
245
+
246
+ // Through the SAME derivation the assertion uses, so narrowing it reds here.
247
+ assert.throws(
248
+ () => assert.doesNotMatch(goalTitleRegions(broken).rowRegion, /subtitleAttr\(g\)/),
249
+ 'the row region does not notice a tooltip added to the row — the check is vacuous again',
250
+ );
251
+ });
252
+
209
253
  test('every CSS token the new styles use actually exists', () => {
210
254
  // copy-desk.css shipped with --line and --panel, which style.css never
211
255
  // defines, so its fallbacks rendered as nothing (idea 1000821).
@@ -534,7 +534,10 @@ test('a backlog row names its craft and its goal', () => {
534
534
  const backlog = workJs.slice(workJs.indexOf('function renderBacklog'));
535
535
  // The same vocabulary the Ready card has carried since 1755 — a second spelling
536
536
  // would let the two tabs describe one task differently.
537
- assert.match(backlog, /const discipline = t\.discipline \|\| 'unclassified'/);
537
+ // task 1003936: the backlog row reads the ONE role vocabulary instead of the
538
+ // stored default. Same invariant (the row carries a role tag), new spelling.
539
+ assert.match(backlog, /window\.OTB\.roleIsUnset\(t\.discipline\)/);
540
+ assert.match(backlog, /window\.OTB\.roleLabel\(t\.discipline\)/);
538
541
  assert.match(backlog, /disciplineTag/, 'the row renders the discipline tag');
539
542
  assert.match(backlog, /taskState\.goalTitles \|\| \{\}/, 'the goal is named by TITLE, not by bare id');
540
543
  assert.match(backlog, /'No goal'/, 'and a goal-less row says so rather than rendering a gap');
@@ -65,6 +65,13 @@ function hallRender(joinability) {
65
65
  const sandbox = {
66
66
  window: {
67
67
  OTB: {
68
+ // task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
69
+ // the same reason categoryChip is: an undefined helper throws mid-render.
70
+ roleAxisLabel: () => 'Role',
71
+ roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
72
+ roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
73
+ roleIsUnset: (d) => !d || d === 'unclassified',
74
+ ROLE_UNSET: 'unclassified',
68
75
  escapeHtml,
69
76
  $: (sel) => nodes[sel] || null,
70
77
  fmtNum: (n) => String(n),
@@ -68,6 +68,13 @@ async function boot({ meStatus }) {
68
68
  __MODULES__: { enabled: {} },
69
69
  BongosClient: { createClient: () => api },
70
70
  OTB: {
71
+ // task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
72
+ // the same reason categoryChip is: an undefined helper throws mid-render.
73
+ roleAxisLabel: () => 'Role',
74
+ roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
75
+ roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
76
+ roleIsUnset: (d) => !d || d === 'unclassified',
77
+ ROLE_UNSET: 'unclassified',
71
78
  moduleOn: () => true,
72
79
  escapeHtml: (s) => String(s == null ? '' : s),
73
80
  $: () => makeEl(),
@@ -154,6 +154,13 @@ function bootCharts() {
154
154
  window: {
155
155
  __BRANDING__: { currency: { label: 'drachmae' } },
156
156
  OTB: {
157
+ // task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
158
+ // the same reason categoryChip is: an undefined helper throws mid-render.
159
+ roleAxisLabel: () => 'Role',
160
+ roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
161
+ roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
162
+ roleIsUnset: (d) => !d || d === 'unclassified',
163
+ ROLE_UNSET: 'unclassified',
157
164
  escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
158
165
  $: () => null,
159
166
  fmtNum: (n) => String(n ?? 0),
@@ -220,10 +227,14 @@ test('craftMix sorts by count and folds the tail past seven into "other" — nev
220
227
  const rows = ['a', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', null].map((d) => ({ discipline: d }));
221
228
  const mix = plain(C.craftMix(rows));
222
229
  assert.equal(mix.length, 7);
223
- assert.deepEqual(mix[0], { label: 'a', n: 2 });
230
+ // task 1003936: the bucket label is now the DISPLAY name (OTB.roleLabel), so a
231
+ // synthetic 'a' reads 'A'. The invariant under test is the sort and the fold.
232
+ assert.deepEqual(mix[0], { label: 'A', n: 2 });
224
233
  assert.equal(mix[6].label, 'other');
225
234
  assert.equal(mix.reduce((s, k) => s + k.n, 0), rows.length, 'nothing is lost in the fold');
226
- assert.ok(C.craftMix([{ discipline: null }]).some((k) => k.label === 'unclassified'), 'a missing craft reads as unclassified');
235
+ // task 1003936: a missing role is still its own bucket, but the LABEL is a
236
+ // phrase a person would say, not the column default (docs/voice.md V2).
237
+ assert.ok(C.craftMix([{ discipline: null }]).some((k) => k.label === 'No role yet'), 'a missing role reads as "No role yet"');
227
238
  assert.equal(C.craftMix(rows.slice(0, 3)).length, 2, 'under the cap there is no fold');
228
239
  });
229
240
 
@@ -288,6 +299,13 @@ function bootRender({ rows, status = 200, analytics = null, ledger = [] }) {
288
299
  __BRANDING__: { currency: { label: 'drachmae' } },
289
300
  localStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) },
290
301
  OTB: {
302
+ // task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
303
+ // the same reason categoryChip is: an undefined helper throws mid-render.
304
+ roleAxisLabel: () => 'Role',
305
+ roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
306
+ roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
307
+ roleIsUnset: (d) => !d || d === 'unclassified',
308
+ ROLE_UNSET: 'unclassified',
291
309
  escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
292
310
  $: (sel) => (sel === '#charts-grid' ? mount : sel === '#charts-controls' ? controls : null),
293
311
  fmtNum: (n) => String(n ?? 0),
@@ -347,6 +347,13 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
347
347
  __BRANDING__: { currency: { label: 'credits' } }, __MODULES__: { enabled: {} },
348
348
  BongosClient: { createClient: () => client },
349
349
  OTB: {
350
+ // task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
351
+ // the same reason categoryChip is: an undefined helper throws mid-render.
352
+ roleAxisLabel: () => 'Role',
353
+ roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
354
+ roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
355
+ roleIsUnset: (d) => !d || d === 'unclassified',
356
+ ROLE_UNSET: 'unclassified',
350
357
  escapeHtml,
351
358
  $: (sel) => documentObj.querySelector(sel),
352
359
  fmtDate: (iso) => String(iso).slice(0, 10),