@bongos/core 1.19.697 → 1.19.699

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.
package/.bongos-core.json CHANGED
@@ -2,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.697",
6
- "core_contract": "1.19.697",
7
- "source_commit": "bfb9b4492d0f0057725db35914055204f148c032",
5
+ "core_version": "1.19.699",
6
+ "core_contract": "1.19.699",
7
+ "source_commit": "c2fc577575ff838fa4b7c6ef9b487a61834508da",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-13T02:32:59.182Z",
9
+ "built_at": "2026-09-13T03:11:31.535Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 477,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 2627,
20
- "tree_sha256": "aecd13ccb73fd1082fe45b3aa23bab026afc2c358c584c4e188cb8caafcc1199",
20
+ "tree_sha256": "6784a09f48fd3badc907d3157a51dfd66a765084f015b78924eb7229bcb7c757",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2802,7 +2802,7 @@
2802
2802
  {
2803
2803
  "path": "docs/module-api-changelog.md",
2804
2804
  "mode": "0000644",
2805
- "sha256": "8862b2feb53917b00597c8a2a32d75fedbbe24cdb78c12998209f9de39395c15"
2805
+ "sha256": "83f3755afa52e27b8dc1c9cacc65fd57eb20ca11463f1408bb7298279bbf3994"
2806
2806
  },
2807
2807
  {
2808
2808
  "path": "docs/modules-contract.md",
@@ -4267,7 +4267,7 @@
4267
4267
  {
4268
4268
  "path": "modules/dev-box/box-access.js",
4269
4269
  "mode": "0000644",
4270
- "sha256": "7bc7c746f2518cb465773a205c8e9fe7efc30852c857001255be92ad8e340bca"
4270
+ "sha256": "7271dc2c3eab6c8ed274dc90f855ec813eef4e401180a300e31de31dce30aa6c"
4271
4271
  },
4272
4272
  {
4273
4273
  "path": "modules/dev-box/box-credential.js",
@@ -7772,12 +7772,12 @@
7772
7772
  {
7773
7773
  "path": "package-lock.json",
7774
7774
  "mode": "0000644",
7775
- "sha256": "b8fa92bf72071648e7a68331fcd48c316d5f42b4dd22ac58de8c69fa91cdbefb"
7775
+ "sha256": "7235324939dc1acfb616300365334ebf96e054f83b8083b15cd2f99eeb5a6d71"
7776
7776
  },
7777
7777
  {
7778
7778
  "path": "package.json",
7779
7779
  "mode": "0000644",
7780
- "sha256": "1c2dd79228c1d4d99779ed0526734e6f75a68e40026db6665e800862b95cccc5"
7780
+ "sha256": "c650680172226a9259dbb3da9cfc459a3c94f3fa5578be520c27d2b71d0a71d9"
7781
7781
  },
7782
7782
  {
7783
7783
  "path": "public-docs/index.html",
@@ -8072,7 +8072,7 @@
8072
8072
  {
8073
8073
  "path": "scripts/gds/context-pack.js",
8074
8074
  "mode": "0000644",
8075
- "sha256": "caa5dd1628b00569a5593964f1831641ac12c9a85b8ec132964a1baf67c77350"
8075
+ "sha256": "7863284f9d81a9cf77a5d01cff82d119207637090f9ef14012c8689625e93a49"
8076
8076
  },
8077
8077
  {
8078
8078
  "path": "scripts/gds/control-manifest.js",
@@ -9512,7 +9512,7 @@
9512
9512
  {
9513
9513
  "path": "src/bongos/serve-internal.js",
9514
9514
  "mode": "0000644",
9515
- "sha256": "5c99fec0061221143be5e22bb51ca690aec883d9eaf094bd25007d3e21b5f96c"
9515
+ "sha256": "61b13299ffdd7f036d5bc3d364135bd8ca4d3470223001b8ff6cc45d858d6107"
9516
9516
  },
9517
9517
  {
9518
9518
  "path": "src/bongos/uptime-poller.js",
@@ -9542,7 +9542,7 @@
9542
9542
  {
9543
9543
  "path": "src/module-api.js",
9544
9544
  "mode": "0000644",
9545
- "sha256": "aceceda426abd1aa9dd04fdfc69a225e7b33a91b790a823cf10d206698f24c1e"
9545
+ "sha256": "f6283921aa6c4af4de772e638a58ad28111bb8e6664a4bb67e15bc86ad1083f3"
9546
9546
  },
9547
9547
  {
9548
9548
  "path": "src/module-loader/catalog.js",
@@ -9922,7 +9922,7 @@
9922
9922
  {
9923
9923
  "path": "tests/box_access.mjs",
9924
9924
  "mode": "0000644",
9925
- "sha256": "911a764f72976298daa17855acb836d0ada3215b480961ae8607e1fc3634f882"
9925
+ "sha256": "85b0f479d16d53eea0442cab9ea8aa2dd09d95926765b47a142c609af22f6927"
9926
9926
  },
9927
9927
  {
9928
9928
  "path": "tests/box_auth_check.mjs",
@@ -10232,7 +10232,7 @@
10232
10232
  {
10233
10233
  "path": "tests/context_pack.mjs",
10234
10234
  "mode": "0000644",
10235
- "sha256": "09f956c01f169ab7384204a5d43deefa3e57a7d57924072dfa8215f0942c2513"
10235
+ "sha256": "159661396a274740d9164dce8532b54e283ead733301c841078334033784d04f"
10236
10236
  },
10237
10237
  {
10238
10238
  "path": "tests/control_manifest.mjs",
@@ -10327,7 +10327,7 @@
10327
10327
  {
10328
10328
  "path": "tests/currency_label.mjs",
10329
10329
  "mode": "0000644",
10330
- "sha256": "b88f7ab55948f0bfbf0e6982cc96465ff2c34d76b143f40b86f933c323b8ff1d"
10330
+ "sha256": "20994467d64d35647433c320265e360a520391bff974b1a39a918ef66ad7f172"
10331
10331
  },
10332
10332
  {
10333
10333
  "path": "tests/cutover_codemod.mjs",
@@ -1853,5 +1853,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1853
1853
  landed since 1.19.695 with no explicit bump. run 34733145358. (task 1002620)
1854
1854
  1.19.697 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1855
1855
  landed since 1.19.696 with no explicit bump. run 34733301156. (task 1002620)
1856
+ 1.19.698 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1857
+ landed since 1.19.697 with no explicit bump. run 34733917938. (task 1002620)
1858
+ 1.19.699 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1859
+ landed since 1.19.698 with no explicit bump. run 34734848584. (task 1002620)
1856
1860
  ---------------------------------------------------------------------------
1857
1861
  ```
@@ -178,6 +178,24 @@ const BASE_SPARSE_PATHS = [
178
178
  // config/government.json) is host content the box's own builder authors — no
179
179
  // credentials live here (ADR 0022); those are in /etc and the box env.
180
180
  'config',
181
+ // modules/lifecycle — task 1003085. BASE's whole charter is "enough to run the
182
+ // /builder-* CLI", and the CLI does not run without this module:
183
+ // scripts/gds/context-pack.js reads the task enums from
184
+ // modules/lifecycle/task-classifier, and context-pack is on the /builder-start
185
+ // AND /builder-claim paths. On a task-scoped box with NO active claim nothing
186
+ // put lifecycle in scope, so both commands died at import with MODULE_NOT_FOUND
187
+ // — and neither could be used to fix it, because a CLAIM is what fetches the
188
+ // module. The only way out was POST /claims by hand.
189
+ //
190
+ // This is the structural half; context-pack.js also degrades now if the module
191
+ // is absent for any OTHER reason. Both, deliberately: the degrade keeps the CLI
192
+ // alive against a future module gap, and this line is what makes the pack
193
+ // actually COMPLETE on every box rather than quietly reduced.
194
+ //
195
+ // Same posture as the entries above — core-domain work-tracking code, which is
196
+ // methodology and not product runtime, so it belongs in BASE by the same reading
197
+ // that put scripts/ and src/bongos/ here. No secrets; a cone-mode directory.
198
+ 'modules/lifecycle',
181
199
  ];
182
200
 
183
201
  // sparsePathsForScopeKeys — PURE. Given the module KEYS a builder's active claims
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.697",
3
+ "version": "1.19.699",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.697",
9
+ "version": "1.19.699",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.697",
3
+ "version": "1.19.699",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -30,11 +30,30 @@ const path = require('node:path');
30
30
  // The live validators. task-classifier is a pure module (no DB, no network),
31
31
  // so importing it from a CLI is safe — same precedent as next-number.js reading
32
32
  // the repo tree directly. This is the "not hand-maintained" guarantee.
33
- const {
34
- VALID_KINDS,
35
- VALID_DISCIPLINES,
36
- TASK_FIELD_BOUNDS,
37
- } = require('../../modules/lifecycle/task-classifier');
33
+ //
34
+ // OPTIONAL, because the file may not be ON DISK (task 1003085). A task-scoped box
35
+ // (ADR 0148) materializes modules/<key> only for the modules the CLAIM puts in
36
+ // scope, so on a box with NO active claim modules/lifecycle is absent and this
37
+ // require threw MODULE_NOT_FOUND at import time. That killed /builder-start AND
38
+ // /builder-claim — the exact two commands needed to escape the state, since the
39
+ // claim is what would fetch the module. A deadlock, escapable only by calling
40
+ // POST /claims with curl. The header's FAIL-OPEN promise ("must NEVER throw or
41
+ // block a claim") was already the right rule; this require was the line that did
42
+ // not keep it.
43
+ //
44
+ // Degrade by OMITTING the enums, NEVER by hard-coding a copy. A local list of
45
+ // kinds is precisely the drift the "generated from the live validators" design
46
+ // exists to prevent, and it would be WORSE than absence: absence costs the
47
+ // planner one /docs lookup, while a stale list reads as authoritative and is
48
+ // discovered only when POST /tasks rejects it — the guess-and-retry turn this
49
+ // whole pack was built to save.
50
+ let classifier = null;
51
+ try {
52
+ // eslint-disable-next-line global-require
53
+ classifier = require('../../modules/lifecycle/task-classifier');
54
+ } catch (_) {
55
+ // Not materialized on this box. apiContract() reports it honestly below.
56
+ }
38
57
 
39
58
  // Single source of truth for the API prefix (src/bongos/api-prefix.js) so the URL
40
59
  // rename (Part B) flips one constant and this pack follows. Importing a pure src
@@ -46,17 +65,31 @@ const { API_PREFIX } = require('../../src/bongos/api-prefix');
46
65
  // The API contract a planner needs to file/claim without guessing. 'unclassified'
47
66
  // is dropped from both enums: it's the classifier's fallback bucket, never a
48
67
  // value a human should pick.
68
+ //
69
+ // The SHAPE is the same either way — empty arrays and null bounds, never missing
70
+ // keys — because every consumer here and in start.js reads these fields
71
+ // positionally. Returning a short object instead would move the crash from the
72
+ // require to the formatter and fix nothing. `enums_available` is what the
73
+ // formatters branch on, so "we don't know" is said once, in words, rather than
74
+ // rendered as an empty list that looks like "there are none".
49
75
  function apiContract() {
50
- const kinds = [...VALID_KINDS].filter((k) => k !== 'unclassified');
51
- const disciplines = [...VALID_DISCIPLINES].filter((d) => d !== 'unclassified');
52
- return {
76
+ const base = {
53
77
  api_prefix: API_PREFIX,
54
78
  docs_url: '/docs',
55
79
  openapi_url: '/docs/openapi.json',
56
- task_kinds: kinds,
57
- disciplines,
58
- priority: TASK_FIELD_BOUNDS.priority,
59
- manual_degree: TASK_FIELD_BOUNDS.manual_degree,
80
+ enums_available: Boolean(classifier),
81
+ task_kinds: [],
82
+ disciplines: [],
83
+ priority: null,
84
+ manual_degree: null,
85
+ };
86
+ if (!classifier) return base;
87
+ return {
88
+ ...base,
89
+ task_kinds: [...classifier.VALID_KINDS].filter((k) => k !== 'unclassified'),
90
+ disciplines: [...classifier.VALID_DISCIPLINES].filter((d) => d !== 'unclassified'),
91
+ priority: classifier.TASK_FIELD_BOUNDS.priority,
92
+ manual_degree: classifier.TASK_FIELD_BOUNDS.manual_degree,
60
93
  };
61
94
  }
62
95
 
@@ -212,13 +245,20 @@ function formatContextPack(pack) {
212
245
  lines.push(` Held claims: ${formatClaims(claims)}`);
213
246
  lines.push(` API prefix: ${api.api_prefix} (e.g. POST ${api.api_prefix}/tasks, GET ${api.api_prefix}/me)`);
214
247
  lines.push(` API reference: ${api.docs_url} (rendered) · ${api.openapi_url} (OpenAPI 3.1)`);
215
- lines.push(` Task kinds: ${api.task_kinds.join(', ')}`);
216
- lines.push(` Disciplines: ${api.disciplines.join(', ')}`);
217
- const conv = api.priority.convention;
218
- lines.push(
219
- ` Numeric fields: priority int ${api.priority.min}–${api.priority.max} ` +
220
- `(use ${conv.label}) · manual_degree int ${api.manual_degree.min}–${api.manual_degree.max}`
221
- );
248
+ if (api.enums_available) {
249
+ lines.push(` Task kinds: ${api.task_kinds.join(', ')}`);
250
+ lines.push(` Disciplines: ${api.disciplines.join(', ')}`);
251
+ const conv = api.priority.convention;
252
+ lines.push(
253
+ ` Numeric fields: priority int ${api.priority.min}–${api.priority.max} ` +
254
+ `(use ${conv.label}) · manual_degree int ${api.manual_degree.min}–${api.manual_degree.max}`
255
+ );
256
+ } else {
257
+ // Say WHY and WHERE to look. The one thing this line must not do is imply the
258
+ // enums are empty — a planner who reads "kinds:" with nothing after it will
259
+ // invent one.
260
+ lines.push(` Task kinds: unavailable here — modules/lifecycle is not on this box; see ${api.docs_url}`);
261
+ }
222
262
  lines.push(` OS: ${formatOs(os)}`);
223
263
  return lines.join('\n');
224
264
  }
@@ -230,7 +270,9 @@ function formatContextPack(pack) {
230
270
  // noise beyond the shared compass.
231
271
  function formatContextPackCompact(pack) {
232
272
  const { worktree, api } = pack;
233
- const kinds = api.task_kinds.slice(0, 4).join(', ') + (api.task_kinds.length > 4 ? ', …' : '');
273
+ const kinds = api.enums_available
274
+ ? api.task_kinds.slice(0, 4).join(', ') + (api.task_kinds.length > 4 ? ', …' : '')
275
+ : `see ${api.docs_url}`;
234
276
  return [
235
277
  `🧭 Worktree: ${worktree.root} (edit + git here, not the main checkout)`,
236
278
  ` API ${api.api_prefix} · kinds: ${kinds} · full pack on /builder-claim`,
@@ -107,25 +107,66 @@ function themeMarkup(theme) {
107
107
  if (!theme) return '';
108
108
  const decls = [];
109
109
  for (const [key, cssVar] of Object.entries(COLOR_VARS)) {
110
- const v = theme.colors?.[key];
110
+ const v = cssSafe(theme.colors?.[key]);
111
111
  if (v) decls.push(`${cssVar}:${v};`);
112
112
  }
113
- if (theme.fonts?.display) decls.push(`--font-display:${theme.fonts.display};`);
114
- if (theme.fonts?.body) decls.push(`--font-body:${theme.fonts.body};`);
113
+ const display = cssSafe(theme.fonts?.display);
114
+ if (display) decls.push(`--font-display:${display};`);
115
+ const body = cssSafe(theme.fonts?.body);
116
+ if (body) decls.push(`--font-body:${body};`);
115
117
  let out = '';
116
118
  for (const href of theme.fonts?.links || []) {
117
- out += `<link rel="stylesheet" href="${href}">`;
119
+ out += `<link rel="stylesheet" href="${esc(href)}">`;
118
120
  }
119
121
  // `backdrop` paints the page background (e.g. the Cloud Bongos sunrise gradient,
120
122
  // ADR 0064 / R74) over the flat var(--bg); omitted packs (OTB) keep --bg.
121
123
  const css = [];
122
124
  if (decls.length) css.push(`:root{${decls.join('')}}`);
123
- if (theme.backdrop) css.push(`body{background:${theme.backdrop};}`);
125
+ const backdrop = cssSafe(theme.backdrop);
126
+ if (backdrop) css.push(`body{background:${backdrop};}`);
124
127
  if (css.length) out += `<style>${css.join('')}</style>`;
125
- if (theme.favicon) out += `<link rel="icon" href="${theme.favicon}">`;
128
+ // esc() is lossless in an attribute: the HTML parser decodes the entities back
129
+ // before anything reads the URL, so the neutral pack's inline `data:image/svg+xml,
130
+ // <svg …>` favicon resolves to exactly the same bytes it does today.
131
+ if (theme.favicon) out += `<link rel="icon" href="${esc(theme.favicon)}">`;
126
132
  return out;
127
133
  }
128
134
 
135
+ // A pack-supplied value on its way into <style> TEXT. esc() is the wrong tool
136
+ // here and would corrupt the output: CSS is not entity-decoded, so a font stack
137
+ // like `"Cinzel", serif` would reach the browser as `&quot;Cinzel&quot;, serif`
138
+ // and simply not parse. There is no encoding that both survives CSS and
139
+ // neutralizes markup, so this fails closed instead. The only way out of a <style>
140
+ // element is a literal `</style`, every route to it begins with `<`, and no
141
+ // colour, font stack, or backdrop has a legitimate use for one — so a value
142
+ // carrying `<` is dropped whole rather than emitted half-escaped. Dropping one
143
+ // declaration loses that pack's colour and falls back to style.css's default;
144
+ // emitting it loses the page.
145
+ function cssSafe(v) {
146
+ const s = String(v == null ? '' : v);
147
+ return s.includes('<') ? '' : s;
148
+ }
149
+
150
+ // Serialize a pack value for injection INSIDE a <script> element. JSON.stringify
151
+ // alone is not enough: its output is valid JSON, but the browser reads a script
152
+ // body as raw text FIRST, so a `</script>` anywhere in the pack ends the element
153
+ // early and everything after it lands in the document as markup (the latent
154
+ // self-XSS from an operator-authored pack — idea 348, Hacker dispatch on task
155
+ // 1374). Escaping `<` alone closes it: `</script`, `<script`, and `<!--` all need
156
+ // one. `>` and `&` need no escaping here precisely because a classic script body
157
+ // is NOT entity-decoded — there is nothing for them to start. U+2028/U+2029 are
158
+ // escaped as well: legal in string literals since ES2019, so not a break-out on
159
+ // any browser we serve, but free to escape and still fatal to an older parser.
160
+ // `\u003c` is the same character to the JS parser and never one to the HTML
161
+ // parser, so the value the page actually reads is unchanged. Same fix, same
162
+ // reasoning as modules/provisioning/routes/callback-page.js.
163
+ function jsonForScript(value) {
164
+ return JSON.stringify(value)
165
+ .replace(/</g, '\\u003c')
166
+ .replace(/\u2028/g, '\\u2028')
167
+ .replace(/\u2029/g, '\\u2029');
168
+ }
169
+
129
170
  // Inject the client-safe branding pack into a served HTML document (R56 task 1195
130
171
  // + R57 task 1196, ADR 0062 §3):
131
172
  // * `window.__BRANDING__` — a synchronous global so every hall/status script
@@ -145,7 +186,7 @@ function injectBrandingGlobal(html, brand = clientBranding(), mods = clientModul
145
186
  // UI sections for off modules synchronously (no fetch, no race). R60 wires the
146
187
  // actual section-hiding; this exposes the data.
147
188
  const stamped = applyBrandTokens(html, brand);
148
- const tag = `${clientBundleScriptTag()}<script>window.__BRANDING__=${JSON.stringify(brand)};window.__MODULES__=${JSON.stringify(mods)};</script>${themeMarkup(brand.theme)}`;
189
+ const tag = `${clientBundleScriptTag()}<script>window.__BRANDING__=${jsonForScript(brand)};window.__MODULES__=${jsonForScript(mods)};</script>${themeMarkup(brand.theme)}`;
149
190
  return stamped.includes('</head>') ? stamped.replace('</head>', `${tag}</head>`) : tag + stamped;
150
191
  }
151
192
 
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.697'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.699'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -196,7 +196,12 @@ console.log('\nBASE_SPARSE_PATHS — the non-product base a task-scoped box laye
196
196
  t('BASE carries the methodology + tooling, NOT the product/game', () => {
197
197
  // clients/ is load-bearing: cli-lib.js hard-imports clients/bongos-client, so a
198
198
  // cone without it has a fully dead builder CLI on the box (task 1002745).
199
- for (const p of ['.devcontainer', '.claude', 'scripts', 'src/bongos', 'docs', 'infra', 'tests', 'clients']) {
199
+ // modules/lifecycle is load-bearing for the same reason (task 1003085):
200
+ // scripts/gds/context-pack.js reads the task enums from its task-classifier,
201
+ // and context-pack sits on BOTH /builder-start and /builder-claim. A box with
202
+ // no active claim put nothing in scope, so both commands died at import — and
203
+ // neither could fix it, because a CLAIM is what fetches the module.
204
+ for (const p of ['.devcontainer', '.claude', 'scripts', 'src/bongos', 'docs', 'infra', 'tests', 'clients', 'modules/lifecycle']) {
200
205
  assert.ok(BASE.includes(p), `BASE must include ${p}`);
201
206
  }
202
207
  for (const p of ['src/world', 'src/rooms', 'public', 'art', 'modules/status-ui']) {
@@ -339,5 +339,85 @@ t('formatContextPackCompact: two lines, worktree + prefix, no undefined', () =>
339
339
  assert.ok(!/undefined/.test(out));
340
340
  });
341
341
 
342
+ // ---- the task-scoped box with no claim (task 1003085) ----------------------
343
+ //
344
+ // A box scoped to a claim materializes modules/<key> only for the modules that
345
+ // claim put in scope (ADR 0148). With NO active claim, modules/lifecycle is not
346
+ // on disk — and this file's hard `require` of task-classifier threw
347
+ // MODULE_NOT_FOUND at IMPORT time, which killed /builder-start and
348
+ // /builder-claim: the two commands needed to escape the state, because a claim
349
+ // is what fetches the module. The live escape was POST /claims by curl.
350
+ //
351
+ // This reproduces the condition for real rather than asserting on source text:
352
+ // a temp tree holding context-pack.js and the one other thing it imports, with
353
+ // NO modules/ directory at all. A regression re-introducing a bare top-level
354
+ // require fails here at `require(...)`, before any assertion runs.
355
+ const fs = require('node:fs');
356
+ const os = require('node:os');
357
+ const path = require('node:path');
358
+ const url = require('node:url');
359
+
360
+ function boxWithoutLifecycle() {
361
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
362
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ctxpack-nolifecycle-'));
363
+ fs.mkdirSync(path.join(root, 'scripts', 'gds'), { recursive: true });
364
+ fs.mkdirSync(path.join(root, 'src', 'bongos'), { recursive: true });
365
+ fs.copyFileSync(path.join(here, '..', 'scripts', 'gds', 'context-pack.js'),
366
+ path.join(root, 'scripts', 'gds', 'context-pack.js'));
367
+ // The ONLY other import. Deliberately copied rather than stubbed: if
368
+ // context-pack grows a second hard dependency, this test fails and that new
369
+ // coupling has to be looked at — which is the point.
370
+ fs.copyFileSync(path.join(here, '..', 'src', 'bongos', 'api-prefix.js'),
371
+ path.join(root, 'src', 'bongos', 'api-prefix.js'));
372
+ // No modules/ — that IS the box condition.
373
+ assert.ok(!fs.existsSync(path.join(root, 'modules')), 'the fixture must not carry modules/');
374
+ return { root, mod: require(path.join(root, 'scripts', 'gds', 'context-pack.js')) };
375
+ }
376
+
377
+ t('a box with no modules/lifecycle can still LOAD the pack (the deadlock)', () => {
378
+ const { mod } = boxWithoutLifecycle();
379
+ const api = mod.apiContract();
380
+ assert.equal(api.enums_available, false, 'it must say so, not pretend');
381
+ assert.equal(api.api_prefix, '/api/bongos', 'everything not behind the module still works');
382
+ // Shape preserved, so every positional consumer survives.
383
+ assert.deepEqual(api.task_kinds, []);
384
+ assert.deepEqual(api.disciplines, []);
385
+ assert.equal(api.priority, null);
386
+ assert.equal(api.manual_degree, null);
387
+ });
388
+
389
+ t('both formatters render on that box instead of throwing', () => {
390
+ const { mod } = boxWithoutLifecycle();
391
+ const pack = mod.buildContextPack({
392
+ activeClaims: [], cwd: '/fallback', platform: 'linux',
393
+ runGit: () => '/work/tree\n', runWhich: () => '',
394
+ });
395
+ // The crash this moves is subtle: omitting the keys instead of emptying them
396
+ // would just relocate the failure from the require to `api.priority.convention`
397
+ // here — still a dead CLI, one stack frame later.
398
+ const full = mod.formatContextPack(pack);
399
+ assert.ok(!/undefined/.test(full), 'no undefined leaks into the pack');
400
+ assert.match(full, /unavailable here/, 'it names the gap');
401
+ assert.match(full, /\/docs/, 'and says where the real answer is');
402
+ assert.doesNotMatch(full, /Task kinds: {5}$/m, 'never an EMPTY kinds line — that reads as "there are none"');
403
+ const compact = mod.formatContextPackCompact(pack);
404
+ assert.equal(compact.split('\n').length, 2);
405
+ assert.ok(!/undefined/.test(compact));
406
+ });
407
+
408
+ t('with the module present the enums are still the LIVE ones, not a local copy', () => {
409
+ // The guard on the fix: degrading must not have introduced a hand-maintained
410
+ // fallback list. This is the original design promise, re-pinned against the
411
+ // new code path.
412
+ const api = apiContract();
413
+ assert.equal(api.enums_available, true);
414
+ const live = require('../modules/lifecycle/task-classifier');
415
+ assert.deepEqual(api.task_kinds, [...live.VALID_KINDS].filter((k) => k !== 'unclassified'));
416
+ assert.deepEqual(api.disciplines, [...live.VALID_DISCIPLINES].filter((d) => d !== 'unclassified'));
417
+ const src = fs.readFileSync(new URL('../scripts/gds/context-pack.js', import.meta.url), 'utf8');
418
+ assert.doesNotMatch(src, /'(bug|feature|chore|cleanup|verify)'\s*,\s*'/,
419
+ 'a literal kind list in this file is the drift the generated design exists to prevent');
420
+ });
421
+
342
422
  console.log(`\ncontext_pack: ${passed} passed, ${failed} failed`);
343
423
  process.exit(failed ? 1 : 0);
@@ -121,6 +121,89 @@ test('injectBrandingGlobal degrades safely when there is no </head>', () => {
121
121
  assert.ok(out.includes('<div>no head</div>'));
122
122
  });
123
123
 
124
+ // --- task 1001434 (idea 348): everything the pack contributes is escaped for the
125
+ // context it lands in. The pack is operator-authored, so this is not a defence
126
+ // against a hostile visitor — it is a defence against one stray character in a
127
+ // config file silently turning every served page's <head> into markup.
128
+
129
+ // A pack string carrying `</script>` is the whole bug: the browser finds the end
130
+ // of the script element by scanning raw text, before any JS parsing happens.
131
+ const HOSTILE = {
132
+ identity: { productName: '</script><img src=x onerror=alert(1)>', worldName: 'w' },
133
+ copy: { tagline: 'a < b && c > d' },
134
+ };
135
+
136
+ function brandingJson(out) {
137
+ const m = /window\.__BRANDING__=(.*?);window\.__MODULES__=/s.exec(out);
138
+ assert.ok(m, 'the injected branding assignment must be findable');
139
+ return m[1];
140
+ }
141
+
142
+ test('1001434: a `</script>` in the pack cannot close the injected script element', () => {
143
+ const out = injectBrandingGlobal('<head></head>', clientBranding(HOSTILE), {});
144
+ assert.ok(/<script>window\.__BRANDING__=(.*?)<\/script>/s.exec(out), 'the injected block is well-formed');
145
+ // Two closers are correct and expected: the client-bundle <script src=…></script>
146
+ // that leads the tag, and our own terminator. A third would BE the bug — the
147
+ // pack's `</script>` ending the element early. Unfixed, this counts 3.
148
+ assert.equal(out.split('</script>').length - 1, 2, 'no early close');
149
+ assert.ok(!out.includes('<img src=x'), 'the payload never reaches the document as a tag');
150
+ });
151
+
152
+ test('1001434: every `<` in the injected JSON is \\u003c, and the VALUE is unchanged', () => {
153
+ const out = injectBrandingGlobal('<head></head>', clientBranding(HOSTILE), {});
154
+ const json = brandingJson(out);
155
+ assert.ok(!json.includes('<'), 'no raw < survives into the script body');
156
+ assert.ok(json.includes('\\u003c'), 'it is escaped, not stripped');
157
+ // Escaping must be lossless: < is the same character to the JSON parser,
158
+ // so the page reads exactly what the operator configured.
159
+ const parsed = JSON.parse(json);
160
+ assert.equal(parsed.identity.productName, '</script><img src=x onerror=alert(1)>');
161
+ assert.equal(parsed.copy.tagline, 'a < b && c > d', '> and & are left alone — a script body is not entity-decoded');
162
+ });
163
+
164
+ test('1001434: __MODULES__ is escaped too — it rides the same script tag', () => {
165
+ const out = injectBrandingGlobal('<head></head>', clientBranding({}), { enabled: { '</script>': true } });
166
+ assert.equal(out.split('</script>').length - 1, 2, 'the bundle tag and our terminator, and nothing else');
167
+ const m = /window\.__MODULES__=(.*?);<\/script>/s.exec(out);
168
+ assert.ok(m && !m[1].includes('<'));
169
+ assert.deepEqual(JSON.parse(m[1]), { enabled: { '</script>': true } });
170
+ });
171
+
172
+ test('1001434: a theme value carrying `<` is DROPPED rather than breaking out of <style>', () => {
173
+ const m = themeMarkup({
174
+ colors: { bg: '#fff', ink: '</style><script>alert(1)</script>' },
175
+ backdrop: '</style>x',
176
+ fonts: { display: '"Cinzel", serif' },
177
+ });
178
+ assert.ok(!m.includes('<script>'), 'no escape from the style element');
179
+ assert.equal(m.split('</style>').length - 1, 1, 'exactly one style terminator — our own');
180
+ assert.ok(m.includes('--bg:#fff;'), 'the clean declarations still ship');
181
+ assert.ok(!m.includes('--ink:'), 'the poisoned one is dropped whole, not half-escaped');
182
+ assert.ok(!m.includes('body{background:'), 'a poisoned backdrop is dropped too');
183
+ // CSS is not entity-decoded, so esc() here would be a bug of its own: the font
184
+ // stack has to keep its real quotes.
185
+ assert.ok(m.includes('--font-display:"Cinzel", serif;'), 'quotes in a font stack survive intact');
186
+ });
187
+
188
+ test('1001434: a pack cannot break out of the favicon or font-link href attribute', () => {
189
+ const m = themeMarkup({ favicon: 'x"><script>alert(1)</script>', fonts: { links: ['y"><script>alert(2)</script>'] } });
190
+ assert.ok(!m.includes('<script>'), 'neither href can escape its attribute');
191
+ assert.ok(m.includes('&quot;'), 'the quote is entity-escaped, which the parser undoes on read');
192
+ });
193
+
194
+ test('1001434: escaping the favicon is lossless — the neutral inline-SVG data URI still resolves', () => {
195
+ const favicon = "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg'><circle r='8'/></svg>";
196
+ const m = themeMarkup({ favicon });
197
+ const href = /<link rel="icon" href="([^"]*)">/.exec(m);
198
+ assert.ok(href, 'the attribute is still well-formed');
199
+ // Decode the way an HTML parser does; the operator's bytes must come back.
200
+ const decoded = href[1]
201
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>')
202
+ .replace(/&quot;/g, '"').replace(/&#39;/g, "'")
203
+ .replace(/&amp;/g, '&');
204
+ assert.equal(decoded, favicon, 'the browser reads exactly the URI the pack configured');
205
+ });
206
+
124
207
  // --- BV1.R75: module hall-widget script injection (ADR 0062 §4) ---
125
208
 
126
209
  const HALL = '<body><script src="/builders/builders.js?v=2026-06-27-r75" defer></script></body>';