@bongos/core 1.19.871 → 1.19.873

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 (105) hide show
  1. package/.bongos-core.json +167 -97
  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/0180-dependency-intake-and-drift-policy.md +6 -0
  11. package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +10 -0
  12. package/docs/api/openapi.json +61 -3
  13. package/docs/api-reference.md +7 -1
  14. package/docs/architecture.md +7 -7
  15. package/docs/branding-contract.md +1 -1
  16. package/docs/copy-inventory.md +190 -189
  17. package/docs/copy-registry.json +264 -255
  18. package/docs/design/hall-direction-v2.md +2 -2
  19. package/docs/module-api-changelog.md +4 -0
  20. package/docs/onboarding/diagrams/README.md +4 -4
  21. package/docs/onboarding/drachmae-and-karma.md +2 -2
  22. package/docs/onboarding/primer.md +1 -1
  23. package/docs/recipes/README.md +1 -1
  24. package/docs/recipes/bongos-cli-release.md +4 -4
  25. package/docs/recipes/core-release-pipeline.md +3 -3
  26. package/docs/recipes/instance-deploy.md +2 -2
  27. package/docs/recipes/ops-gotchas.md +2 -2
  28. package/docs/recipes/private-npm-distribution.md +16 -21
  29. package/docs/recipes/search-index-ingestion.md +2 -2
  30. package/docs/recipes/upgrading-the-core.md +1 -1
  31. package/modules/autonomy/gauge.js +218 -0
  32. package/modules/dev-box/app/README.md +1 -1
  33. package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
  34. package/modules/hall-ui/public/blockers.js +138 -34
  35. package/modules/hall-ui/public/builders.js +2 -1
  36. package/modules/hall-ui/public/collab.html +2 -2
  37. package/modules/hall-ui/public/collab.js +6 -3
  38. package/modules/hall-ui/public/dom-utils.js +46 -5
  39. package/modules/hall-ui/public/goals-page.js +7 -3
  40. package/modules/hall-ui/public/goals-render.js +4 -3
  41. package/modules/hall-ui/public/hall-render.js +1 -1
  42. package/modules/hall-ui/public/people.js +3 -3
  43. package/modules/hall-ui/public/profile-charts.js +5 -2
  44. package/modules/hall-ui/public/profile.html +3 -3
  45. package/modules/hall-ui/public/profile.js +6 -6
  46. package/modules/hall-ui/public/settings-specialities.js +2 -2
  47. package/modules/hall-ui/public/settings.html +4 -4
  48. package/modules/hall-ui/public/settings.js +2 -2
  49. package/modules/hall-ui/public/shell.js +70 -0
  50. package/modules/hall-ui/public/style.css +31 -0
  51. package/modules/hall-ui/public/work.js +12 -14
  52. package/modules/provisioning/module.json +3 -3
  53. package/modules/public-landing/public/terms.html +1 -1
  54. package/package-lock.json +2 -2
  55. package/package.json +1 -1
  56. package/scripts/gds/autobongos-loop.js +170 -0
  57. package/scripts/gds/autobongos-run.js +317 -0
  58. package/scripts/gds/autobongos-verify.js +377 -0
  59. package/scripts/gds/autonomy-gauge-statusline.js +124 -0
  60. package/scripts/gds/autonomy-gauge.js +84 -0
  61. package/scripts/gds/provision-repo.js +4 -2
  62. package/scripts/gds/run-unit-tests.js +120 -2
  63. package/scripts/gds/sequence.js +3 -0
  64. package/scripts/gds/start.js +16 -10
  65. package/src/bongos/core-update.js +147 -0
  66. package/src/bongos/routes/core-update.js +49 -0
  67. package/src/bongos/routes.js +2 -0
  68. package/src/module-api.js +1 -1
  69. package/tests/account_privacy_flags.mjs +3 -1
  70. package/tests/autobongos_loop.mjs +446 -0
  71. package/tests/autobongos_verify.mjs +325 -0
  72. package/tests/autonomy_gauge.mjs +344 -0
  73. package/tests/blockers_hall.mjs +20 -6
  74. package/tests/blockers_kit_pager.mjs +260 -0
  75. package/tests/blockers_page_ui.mjs +5 -4
  76. package/tests/box_ship_permissions.mjs +30 -0
  77. package/tests/core_update_banner.mjs +308 -0
  78. package/tests/eviction_policy.mjs +22 -1
  79. package/tests/git_guard.mjs +68 -0
  80. package/tests/goal_criterion_confirm_ui.mjs +7 -0
  81. package/tests/goal_invite_consent.mjs +7 -0
  82. package/tests/goal_sort_staleness.mjs +6 -2
  83. package/tests/hall_board_world.mjs +4 -1
  84. package/tests/hall_builders_page_pass.mjs +7 -0
  85. package/tests/hall_landing_boot.mjs +7 -0
  86. package/tests/hall_profile_world.mjs +20 -2
  87. package/tests/hall_record_world.mjs +7 -0
  88. package/tests/hall_roster_world.mjs +2 -1
  89. package/tests/hall_studio_world.mjs +7 -0
  90. package/tests/hook_shell_tools.mjs +23 -1
  91. package/tests/idea_detail_page.mjs +7 -0
  92. package/tests/idea_spark_hall.mjs +7 -0
  93. package/tests/integration_parked_ratchet.mjs +79 -0
  94. package/tests/module_contributions.mjs +134 -8
  95. package/tests/package_core.mjs +31 -0
  96. package/tests/people_directory_clip.mjs +7 -0
  97. package/tests/profile_activity.mjs +7 -0
  98. package/tests/profile_ui_cross_project.mjs +9 -1
  99. package/tests/provision_disconnect.mjs +7 -0
  100. package/tests/provision_restart.mjs +7 -0
  101. package/tests/provision_settings_apply.mjs +7 -0
  102. package/tests/sessions_kit_pager.mjs +7 -0
  103. package/tests/watch_applications_queue.mjs +7 -0
  104. package/tests/watch_roster_self_row.mjs +7 -0
  105. 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 ---------------------
@@ -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),
@@ -231,7 +231,8 @@ test('the directory has a search, a sort and a craft lens on the kit bar, and pa
231
231
  assert.match(src, /kit\.makeFilterBar\(\{/);
232
232
  assert.match(src, /search: \{ id: 'people-q'/);
233
233
  assert.match(src, /\{ id: 'people-sort', label: 'Sort'/);
234
- assert.match(src, /\{ id: 'people-craft', label: 'Craft'/);
234
+ // task 1003936: the axis label is read from OTB.roleAxisLabel(), never typed.
235
+ assert.match(src, /\{ id: 'people-craft', label: window\.OTB\.roleAxisLabel\(\)/);
235
236
  assert.match(src, /kit\.makePager\('people'/);
236
237
  assert.match(src, /kit\.makeLedger\(\{/);
237
238
  assert.doesNotMatch(src, /createElement\('table'\)/, 'no hand-built table');
@@ -259,6 +259,13 @@ async function bootStudio({ reviews = [], shipped = {}, rank = 'metic', flags =
259
259
  const window = {
260
260
  BongosClient: { createClient: () => ({ request }) },
261
261
  OTB: {
262
+ // task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
263
+ // the same reason categoryChip is: an undefined helper throws mid-render.
264
+ roleAxisLabel: () => 'Role',
265
+ roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
266
+ roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
267
+ roleIsUnset: (d) => !d || d === 'unclassified',
268
+ ROLE_UNSET: 'unclassified',
262
269
  escapeHtml: (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
263
270
  $: (sel) => el(String(sel).replace(/^#/, '')),
264
271
  toast: () => {},
@@ -26,7 +26,7 @@ import { fileURLToPath } from 'node:url';
26
26
 
27
27
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
28
28
  const require = createRequire(import.meta.url);
29
- const { SHELL_TOOLS, MATCHER, isShellTool } = require(
29
+ const { SHELL_TOOLS, MATCHER, isShellTool, escapesWithBacktick } = require(
30
30
  path.join(ROOT, '.claude', 'hooks', 'shell-tools.js'),
31
31
  );
32
32
 
@@ -113,3 +113,25 @@ test('isShellTool admits every listed tool and nothing else, and never throws',
113
113
  assert.equal(isShellTool(junk), false);
114
114
  }
115
115
  });
116
+
117
+ // task 1004028 — the shells differ, and a guard that reads a command literally reads
118
+ // the wrong thing on the one whose escape character vanishes before its parser.
119
+ test('escapesWithBacktick names PowerShell and not Bash, and never throws', () => {
120
+ assert.equal(escapesWithBacktick('PowerShell'), true);
121
+ assert.equal(escapesWithBacktick('Bash'), false, 'a backtick opens a command substitution in Bash');
122
+ for (const other of ['powershell', 'Write', '']) assert.equal(escapesWithBacktick(other), false, other);
123
+ for (const junk of [undefined, null, 0, {}, []]) assert.equal(escapesWithBacktick(junk), false);
124
+ });
125
+
126
+ // A tool whose escape rules a guard normalizes must be a tool a guard is reached on
127
+ // at all — otherwise the entry is inert and reads as coverage that does not exist.
128
+ test('every backtick-escaping tool is itself a shell tool', () => {
129
+ for (const tool of SHELL_TOOLS.filter((t) => escapesWithBacktick(t))) assert.ok(isShellTool(tool));
130
+ const src = fs.readFileSync(path.join(ROOT, '.claude', 'hooks', 'shell-tools.js'), 'utf8');
131
+ const listed = (src.match(/const BACKTICK_ESCAPE_TOOLS = \[([^\]]*)\]/) || [, ''])[1]
132
+ .split(',').map((s) => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
133
+ assert.ok(listed.length, 'BACKTICK_ESCAPE_TOOLS could not be read');
134
+ for (const tool of listed) {
135
+ assert.ok(SHELL_TOOLS.includes(tool), `BACKTICK_ESCAPE_TOOLS names "${tool}", which is not a shell tool`);
136
+ }
137
+ });