@bongos/core 1.19.789 → 1.19.791

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 (64) hide show
  1. package/.bongos-core.json +143 -53
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +6 -0
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0293-the-owners-deploy-door-is-an-intent-the-control-plane-drains.md +124 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +129 -1
  10. package/docs/api-reference.md +5 -2
  11. package/docs/copy-inventory.md +89 -72
  12. package/docs/copy-registry.json +272 -98
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/recipes/core-release-pipeline.md +12 -0
  15. package/migrations/core_241_core_upgrades_actor.sql +65 -0
  16. package/migrations/core_242_core_version_snapshot.sql +54 -0
  17. package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
  18. package/modules/government/catalog.js +8 -0
  19. package/modules/government/migrations/government_014_core_pin_move.sql +46 -0
  20. package/modules/hall-ui/public/deploy.html +64 -0
  21. package/modules/hall-ui/public/deploy.js +188 -0
  22. package/modules/hall-ui/public/hall-kit.js +7 -65
  23. package/modules/hall-ui/public/hall-render.js +1 -1
  24. package/modules/hall-ui/public/hall-visual.js +85 -0
  25. package/modules/hall-ui/public/index.html +1 -0
  26. package/modules/hall-ui/public/profile.html +1 -0
  27. package/modules/hall-ui/public/profile.js +2 -2
  28. package/modules/hall-ui/public/shell.js +4 -0
  29. package/modules/hall-ui/public/studio.css +1 -1
  30. package/modules/hall-ui/public/studio.html +1 -0
  31. package/modules/hall-ui/public/studio.js +4 -3
  32. package/modules/hall-ui/public/style.css +0 -16
  33. package/modules/hall-ui/public/task.html +1 -0
  34. package/modules/hall-ui/public/task.js +10 -13
  35. package/modules/provisioning/control-plane-guard.js +102 -0
  36. package/modules/provisioning/core-upgrade.js +289 -0
  37. package/modules/provisioning/migrations/provisioning_018_core_upgrade_intent.sql +98 -0
  38. package/modules/provisioning/module.json +1 -1
  39. package/modules/provisioning/provisioning.js +8 -8
  40. package/modules/provisioning/routes/core-upgrade.js +95 -0
  41. package/package-lock.json +2 -2
  42. package/package.json +1 -1
  43. package/scripts/gds/accessibility-replay.js +195 -0
  44. package/scripts/gds/enroll-control-plane.js +101 -0
  45. package/scripts/gds/provision-core-upgrade.js +311 -0
  46. package/scripts/gds/provision.js +4 -2
  47. package/scripts/gds/upgrade.js +53 -14
  48. package/src/bongos/auth.js +19 -0
  49. package/src/bongos/serve-internal.js +21 -0
  50. package/src/module-api.js +1 -1
  51. package/tests/accessibility_replay.mjs +201 -0
  52. package/tests/auth_resolve_failure.mjs +4 -2
  53. package/tests/core_upgrade_door.mjs +408 -0
  54. package/tests/core_upgrade_runner.mjs +377 -0
  55. package/tests/fixtures/accessibility-replay.json +93 -0
  56. package/tests/hall_nav.mjs +4 -1
  57. package/tests/hall_page_gate_map.mjs +7 -0
  58. package/tests/hall_studio_world.mjs +19 -11
  59. package/tests/provisioning_disconnect.mjs +30 -5
  60. package/tests/provisioning_paid_shape_gate.mjs +14 -4
  61. package/tests/provisioning_restart.mjs +25 -9
  62. package/tests/provisioning_settings_apply.mjs +11 -2
  63. package/tests/provisioning_teardown_intent.mjs +7 -0
  64. package/tests/task_visuals.mjs +91 -27
@@ -161,10 +161,20 @@ await ta('a case-variant shape does not slip past the gate INTO a dedicated row'
161
161
  }
162
162
  });
163
163
 
164
- await ta('HOSTING_SHAPES still carries exactly the three shapes the gate reasons about', () => {
165
- // If a fourth, also-metered shape is ever added, this test is the tripwire that says the
166
- // gate's single-shape assumption needs revisiting.
167
- assert.deepEqual([...provisioning.HOSTING_SHAPES].sort(), ['co-tenant', 'dedicated', 'standalone']);
164
+ await ta('HOSTING_SHAPES carries the shapes the gate reasons about, and control-plane is NOT metered', () => {
165
+ // This tripwire fired for the first time at task 1003159, which added a fourth
166
+ // shape — 'control-plane', the platform's own box, enrolled so the owner's deploy
167
+ // door has a row to point at (ADR 0293 D2). Its question was "is the new shape
168
+ // also metered?", and the answer is NO, stated here rather than implied by
169
+ // absence: the control plane is infrastructure the project already runs and pays
170
+ // for, not a droplet allocated per request, so nothing about standing it up is
171
+ // billed through this gate. It is also not creatable through the create route at
172
+ // all — the self row is enrolled by the platform, not asked for by a builder.
173
+ assert.deepEqual([...provisioning.HOSTING_SHAPES].sort(),
174
+ ['co-tenant', 'control-plane', 'dedicated', 'standalone']);
175
+ // The assumption the tripwire actually guards is that exactly ONE shape is
176
+ // metered. A fifth shape that IS billed must widen the gate itself, in
177
+ // paid-shape-gate.js, and not merely this list.
168
178
  });
169
179
 
170
180
  server.close();
@@ -29,7 +29,7 @@
29
29
 
30
30
  import { strict as assert } from 'node:assert';
31
31
  import { createRequire } from 'node:module';
32
- import { readFileSync } from 'node:fs';
32
+ import { readFileSync, readdirSync } from 'node:fs';
33
33
  import http from 'node:http';
34
34
 
35
35
  const require = createRequire(import.meta.url);
@@ -51,17 +51,33 @@ const PROMISE = /shortly|briefly|minute|second|soon\b/i;
51
51
 
52
52
  console.log('the module slice — constants, the migration, the enqueue classifier:');
53
53
 
54
- t("INTENT_ACTIONS includes 'restart', in lockstep with the provisioning_011 CHECK", () => {
54
+ t("INTENT_ACTIONS includes 'restart', in lockstep with the LATEST action CHECK", () => {
55
+ // Pinned to provisioning_011 by name until task 1003159, which added
56
+ // 'core-upgrade' in provisioning_016 — and a lockstep assertion aimed at a
57
+ // superseded migration compares the live vocabulary against a frozen one, so it
58
+ // reds on the next widening rather than on a real drift. It now reads whichever
59
+ // migration most recently defines the CHECK, the shape
60
+ // tests/claimable_blocker_status.mjs uses for the claimable view.
55
61
  assert.ok(provisioning.INTENT_ACTIONS.includes('restart'));
56
- const sql = readFileSync(new URL('../modules/provisioning/migrations/provisioning_011_restart.sql', import.meta.url), 'utf8');
62
+ const migDir = new URL('../modules/provisioning/migrations/', import.meta.url);
63
+ const defining = readdirSync(migDir)
64
+ .filter((f) => f.endsWith('.sql'))
65
+ .filter((f) => /CHECK \(action IN \(/.test(readFileSync(new URL(f, migDir), 'utf8')))
66
+ .sort();
67
+ assert.ok(defining.length, 'some migration defines the action CHECK');
68
+ const latest = defining[defining.length - 1];
69
+ const sql = readFileSync(new URL(latest, migDir), 'utf8');
57
70
  const m = /CHECK \(action IN \(([^)]*)\)\)/.exec(sql);
58
71
  assert.ok(m, 'the migration widens the action CHECK');
59
- const inSql = m[1].split(',').map((s) => s.trim().replace(/^'|'$/g, ''));
60
- assert.ok(inSql.includes('restart'), 'this migration is the one that admitted it');
61
- // The NEWEST action migration owns the strict set equality (provisioning_016,
62
- // tests/provisioning_disconnect.mjs); an older one is a prefix of the set.
63
- for (const a of inSql) assert.ok(provisioning.INTENT_ACTIONS.includes(a), `${a} is still an INTENT_ACTION`);
64
- assert.match(sql, /INSERT INTO schema_migrations \(version\) VALUES \('provisioning_011_restart'\)/, 'self-registers');
72
+ const inSql = m[1].split(',').map((s) => s.trim().replace(/^'|'$/g, '')).sort();
73
+ // The NEWEST migration owns the strict set equality — the CHECK a live DB ends up
74
+ // with is the last one applied, so this is the assertion that would catch an action
75
+ // added in code but never admitted by the schema. That is provisioning_018 today:
76
+ // 016_disconnect and 018_core_upgrade_intent each rewrite the CHECK, so the later
77
+ // one must carry BOTH actions or the earlier one's is silently dropped.
78
+ assert.deepEqual(inSql, [...provisioning.INTENT_ACTIONS].sort(), 'the CHECK and INTENT_ACTIONS carry the same set');
79
+ const sql011 = readFileSync(new URL('provisioning_011_restart.sql', migDir), 'utf8');
80
+ assert.match(sql011, /INSERT INTO schema_migrations \(version\) VALUES \('provisioning_011_restart'\)/, 'self-registers');
65
81
  });
66
82
 
67
83
  t('RESTART_FROM is exactly {active} — every other state is already served by the provision path', () => {
@@ -84,8 +84,17 @@ await ta('enqueueSettingsApply: a fresh intent → created:true; the INSERT carr
84
84
  const r = await provisioning.enqueueSettingsApply(db, 18, 'api:self');
85
85
  assert.equal(r.created, true);
86
86
  assert.equal(r.conflict, undefined);
87
- assert.match(calls[0].sql, /INSERT INTO provisioning_intents/);
88
- assert.deepEqual(calls[0].params, [18, 'settings-apply', 'api:self']);
87
+ // calls[0] is the self-row guard's shape lookup (ADR 0293 D2) — every enqueue now
88
+ // asks "is this the control plane?" before it writes. Assert on the INSERT itself
89
+ // rather than on a position, so this test says what it means and does not move
90
+ // again the next time a check joins the front of this function.
91
+ const insert = calls.find((c) => /INSERT INTO provisioning_intents/.test(c.sql));
92
+ assert.ok(insert, 'the enqueue reaches its INSERT');
93
+ assert.match(calls[0].sql, /SELECT hosting_shape FROM provisioning_instances/, 'and the guard ran FIRST');
94
+ // The trailing null is the target_version enqueueIntent gained in task 1003159:
95
+ // only a 'core-upgrade' names one, and the column's CHECK is an EQUIVALENCE, so a
96
+ // settings-apply carrying a target would be refused by the database.
97
+ assert.deepEqual(insert.params, [18, 'settings-apply', 'api:self', null]);
89
98
  });
90
99
 
91
100
  await ta('enqueueSettingsApply: an identical PENDING push already open → created:false, no conflict (it carries this change)', async () => {
@@ -55,6 +55,13 @@ function fakeIntentDb(seed = []) {
55
55
  return {
56
56
  rows,
57
57
  query: async (sql, params) => {
58
+ // The self-row guard's shape lookup (task 1003159, ADR 0293 D2): every enqueue
59
+ // now asks "is this the control plane?" before it writes. These cases are all
60
+ // ORDINARY instances, so the honest answer is a tenant shape — answering
61
+ // 'control-plane' here would refuse every teardown in this file.
62
+ if (/SELECT hosting_shape FROM provisioning_instances/.test(sql)) {
63
+ return { rows: [{ hosting_shape: 'co-tenant' }] };
64
+ }
58
65
  if (/INSERT INTO provisioning_intents/.test(sql)) {
59
66
  const [instance_id, action, requested_by] = params;
60
67
  if (open(instance_id).length) {
@@ -13,13 +13,16 @@ import { createRequire } from 'node:module';
13
13
  import os from 'node:os';
14
14
  import path from 'node:path';
15
15
  import fs from 'node:fs/promises';
16
+ import fsSync from 'node:fs';
16
17
  const require = createRequire(import.meta.url);
17
18
 
18
19
  // Point storage at a throwaway dir BEFORE requiring the module.
19
20
  const TMP = path.join(os.tmpdir(), `otb-taskvisual-${process.pid}`);
20
21
  process.env.TASK_VISUALS_DIR = TMP;
21
22
  const tv = require('../modules/lifecycle/task-visuals.js');
22
- const kit = require('../modules/hall-ui/public/hall-kit.js');
23
+ // The renderers left hall-kit.js for hall-visual.js (task 1003835) — the task
24
+ // record page draws a visual and needs nothing else the kit holds.
25
+ const vis = require('../modules/hall-ui/public/hall-visual.js');
23
26
 
24
27
  let passed = 0, failed = 0;
25
28
  function t(name, fn) {
@@ -140,7 +143,7 @@ await t('persistImageBuffer: writes a name the serve route accepts, under the ri
140
143
  assert.equal(tv.taskIdFromName(r.file), 1003109);
141
144
  assert.equal(r.url, `/api/bongos/task-visuals/${r.file}`);
142
145
  // Both prefixes are permanent aliases, so a URL minted under either resolves.
143
- assert.equal(kit.isTaskVisualUrl(r.url), true);
146
+ assert.equal(vis.isTaskVisualUrl(r.url), true);
144
147
  assert.equal(r.bytes, PNG.length);
145
148
  const onDisk = await fs.readFile(path.join(TMP, r.file));
146
149
  assert.deepEqual(onDisk, PNG);
@@ -170,23 +173,23 @@ await t('deleteStored: removes by name or URL, is idempotent, refuses a traversa
170
173
  });
171
174
 
172
175
  // ---- the client-side URL guard --------------------------------------------
173
- await t('hall-kit: only a server-minted visual URL is ever rendered', () => {
174
- assert.equal(kit.isTaskVisualUrl('/api/gds/task-visuals/task-1003109-0011223344556677.png'), true);
175
- assert.equal(kit.isTaskVisualUrl('/api/bongos/task-visuals/task-1-aabbccddeeff0011.webp'), true);
176
+ await t('hall-visual: only a server-minted visual URL is ever rendered', () => {
177
+ assert.equal(vis.isTaskVisualUrl('/api/gds/task-visuals/task-1003109-0011223344556677.png'), true);
178
+ assert.equal(vis.isTaskVisualUrl('/api/bongos/task-visuals/task-1-aabbccddeeff0011.webp'), true);
176
179
  // Anything that is not that exact shape renders nothing — including an
177
180
  // absolute URL to another origin, and a javascript: payload.
178
- assert.equal(kit.isTaskVisualUrl('https://evil.example/x.png'), false);
179
- assert.equal(kit.isTaskVisualUrl('javascript:alert(1)'), false);
180
- assert.equal(kit.isTaskVisualUrl('/api/gds/task-visuals/../../secrets'), false);
181
- assert.equal(kit.isTaskVisualUrl(''), false);
182
- assert.equal(kit.isTaskVisualUrl(null), false);
183
- });
184
-
185
- await t('hall-kit: no visual renders nothing at all — never a placeholder', () => {
186
- assert.equal(kit.shipVisualThumbHtml({ id: 1 }), '');
187
- assert.equal(kit.shipVisualThumbHtml({ id: 1, visual_url: null }), '');
188
- assert.equal(kit.shipVisualThumbHtml(null), '');
189
- const html = kit.shipVisualThumbHtml({ id: 1, visual_url: '/api/gds/task-visuals/task-1-0011223344556677.png', visual_alt: 'a "before" shot' });
181
+ assert.equal(vis.isTaskVisualUrl('https://evil.example/x.png'), false);
182
+ assert.equal(vis.isTaskVisualUrl('javascript:alert(1)'), false);
183
+ assert.equal(vis.isTaskVisualUrl('/api/gds/task-visuals/../../secrets'), false);
184
+ assert.equal(vis.isTaskVisualUrl(''), false);
185
+ assert.equal(vis.isTaskVisualUrl(null), false);
186
+ });
187
+
188
+ await t('hall-visual: no visual renders nothing at all — never a placeholder', () => {
189
+ assert.equal(vis.shipVisualThumbHtml({ id: 1 }), '');
190
+ assert.equal(vis.shipVisualThumbHtml({ id: 1, visual_url: null }), '');
191
+ assert.equal(vis.shipVisualThumbHtml(null), '');
192
+ const html = vis.shipVisualThumbHtml({ id: 1, visual_url: '/api/gds/task-visuals/task-1-0011223344556677.png', visual_alt: 'a "before" shot' });
190
193
  assert.ok(html.includes('ship-visual-thumb'));
191
194
  // The caption is attacker-adjacent text (a builder wrote it) — it must be escaped.
192
195
  assert.ok(html.includes('"before"'));
@@ -194,18 +197,18 @@ await t('hall-kit: no visual renders nothing at all — never a placeholder', ()
194
197
  });
195
198
 
196
199
  // ---- the full-size figure (task 1003829) -----------------------------------
197
- await t('hall-kit: the full-size figure is the same guard, at reading size', () => {
200
+ await t('hall-visual: the full-size figure is the same guard, at reading size', () => {
198
201
  // Same no-placeholder rule as the thumbnail: nothing at all without a visual.
199
- assert.equal(kit.taskVisualFigureHtml({ id: 1 }), '');
200
- assert.equal(kit.taskVisualFigureHtml({ id: 1, visual_url: null }), '');
201
- assert.equal(kit.taskVisualFigureHtml(null), '');
202
+ assert.equal(vis.taskVisualFigureHtml({ id: 1 }), '');
203
+ assert.equal(vis.taskVisualFigureHtml({ id: 1, visual_url: null }), '');
204
+ assert.equal(vis.taskVisualFigureHtml(null), '');
202
205
  // And the same URL wall — an off-origin src or a javascript: payload is not
203
206
  // "rendered without a caption", it is not rendered.
204
- assert.equal(kit.taskVisualFigureHtml({ id: 1, visual_url: 'https://evil.example/x.png' }), '');
205
- assert.equal(kit.taskVisualFigureHtml({ id: 1, visual_url: 'javascript:alert(1)' }), '');
207
+ assert.equal(vis.taskVisualFigureHtml({ id: 1, visual_url: 'https://evil.example/x.png' }), '');
208
+ assert.equal(vis.taskVisualFigureHtml({ id: 1, visual_url: 'javascript:alert(1)' }), '');
206
209
 
207
210
  const url = '/api/gds/task-visuals/task-1-0011223344556677.png';
208
- const html = kit.taskVisualFigureHtml({ id: 1, visual_url: url, visual_alt: 'a "before" shot' });
211
+ const html = vis.taskVisualFigureHtml({ id: 1, visual_url: url, visual_alt: 'a "before" shot' });
209
212
  assert.ok(html.includes('<figure class="task-visual">'));
210
213
  assert.ok(html.includes('class="task-visual__img"'));
211
214
  // The caption is the SHIPPER's text — attacker-adjacent, so escaped in both
@@ -215,19 +218,80 @@ await t('hall-kit: the full-size figure is the same guard, at reading size', ()
215
218
 
216
219
  // The caller owns the size, so it owns the class base; anything but
217
220
  // [A-Za-z0-9_-] is stripped rather than interpolated into the attribute.
218
- const studio = kit.taskVisualFigureHtml({ id: 1, visual_url: url }, { className: 'studio-plate' });
221
+ const studio = vis.taskVisualFigureHtml({ id: 1, visual_url: url }, { className: 'studio-plate' });
219
222
  assert.ok(studio.includes('<figure class="studio-plate">'));
220
223
  assert.ok(studio.includes('class="studio-plate__img"'));
221
- const hostile = kit.taskVisualFigureHtml({ id: 1, visual_url: url }, { className: 'x" onload="a' });
224
+ const hostile = vis.taskVisualFigureHtml({ id: 1, visual_url: url }, { className: 'x" onload="a' });
222
225
  assert.ok(hostile.includes('<figure class="xonloada">'), hostile);
223
226
 
224
227
  // No written alt → no caption at all. The fallback alt is a machine sentence
225
228
  // for a screen reader, never a caption shown to a reader.
226
- const bare = kit.taskVisualFigureHtml({ id: 7, visual_url: url });
229
+ const bare = vis.taskVisualFigureHtml({ id: 7, visual_url: url });
227
230
  assert.ok(!bare.includes('figcaption'));
228
231
  assert.ok(bare.includes('alt="Visual for task 7"'));
229
232
  });
230
233
 
234
+ // ---- the convergence is the deliverable (task 1003835) ---------------------
235
+ //
236
+ // Reading the source is the right altitude here: the defect this task closes is
237
+ // a SECOND definition existing, which no behavioural assertion against one of
238
+ // them can see. A duplicate renderer passes every test above.
239
+
240
+ const HALL = (f) => fsSync.readFileSync(new URL(`../modules/hall-ui/public/${f}`, import.meta.url), 'utf8');
241
+
242
+ await t('hall-kit no longer defines a visual renderer', () => {
243
+ const kitSrc = HALL('hall-kit.js');
244
+ // The function DEFINITIONS are what must be gone — the pointer comment naming
245
+ // them is the thing that tells the next reader where they went, so match on
246
+ // the definition syntax rather than on the bare name.
247
+ assert.ok(!/function\s+taskVisualFigureHtml\s*\(/.test(kitSrc), 'hall-kit.js still defines taskVisualFigureHtml');
248
+ assert.ok(!/function\s+shipVisualThumbHtml\s*\(/.test(kitSrc), 'hall-kit.js still defines shipVisualThumbHtml');
249
+ assert.ok(!/function\s+isTaskVisualUrl\s*\(/.test(kitSrc), 'hall-kit.js still defines isTaskVisualUrl');
250
+ // …and it must not re-export them either, or callers keep two ways in.
251
+ const kitApi = require('../modules/hall-ui/public/hall-kit.js');
252
+ assert.equal(kitApi.taskVisualFigureHtml, undefined);
253
+ assert.equal(kitApi.shipVisualThumbHtml, undefined);
254
+ assert.equal(kitApi.isTaskVisualUrl, undefined);
255
+ });
256
+
257
+ await t('task.js renders through the shared module, not its own <figure>', () => {
258
+ const src = HALL('task.js');
259
+ assert.ok(src.includes('vis.taskVisualFigureHtml(t, { className: \'tk-visual\' })'),
260
+ 'task.js should delegate to OTBVisual with its own class base');
261
+ // The hand-rolled markup is the thing that came back three times. If any of it
262
+ // reappears in this file, the convergence has been undone.
263
+ assert.ok(!src.includes('<figure class="tk-visual">'), 'task.js hand-rolls the figure again');
264
+ assert.ok(!src.includes('tk-visual__img'), 'task.js hand-rolls the img class again');
265
+ assert.ok(!src.includes('task-visuals/task-'), 'task.js carries its own copy of the URL guard again');
266
+ });
267
+
268
+ await t('every page whose script draws a visual actually loads hall-visual.js', () => {
269
+ // A missing <script> is the one failure this split introduces, and it is
270
+ // silent: the guard in each caller degrades to rendering nothing, so a visual
271
+ // would simply stop appearing with no error anywhere.
272
+ for (const page of ['index.html', 'profile.html', 'studio.html', 'task.html']) {
273
+ assert.ok(HALL(page).includes('/builders/hall-visual.js'), `${page} does not load hall-visual.js`);
274
+ }
275
+ });
276
+
277
+ await t('the callers reach for OTBVisual, and no longer for the kit', () => {
278
+ for (const f of ['hall-render.js', 'profile.js', 'studio.js', 'task.js']) {
279
+ const src = HALL(f);
280
+ assert.ok(!/OTBKit(\.|\[')?\s*(shipVisualThumbHtml|taskVisualFigureHtml|isTaskVisualUrl)/.test(src),
281
+ `${f} still reads a visual renderer off OTBKit`);
282
+ }
283
+ assert.ok(HALL('hall-render.js').includes('window.OTBVisual.shipVisualThumbHtml'));
284
+ assert.ok(HALL('studio.js').includes('vis.taskVisualFigureHtml('));
285
+ });
286
+
287
+ await t('the dead .td-visual rules are gone, and nothing emits the class', () => {
288
+ // task 1003829's comment and this task's own brief both asserted a second
289
+ // renderer in task-detail.js. `git log -S` shows that file never carried one:
290
+ // the CSS was the fossil of a renderer that was planned and not built.
291
+ assert.ok(!HALL('style.css').includes('.td-visual'), 'style.css still carries the dead rules');
292
+ assert.ok(!HALL('task-detail.js').includes('td-visual'));
293
+ });
294
+
231
295
  // ---- cleanup ---------------------------------------------------------------
232
296
  await fs.rm(TMP, { recursive: true, force: true });
233
297