@bongos/core 1.19.1060 → 1.19.1062

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.
@@ -0,0 +1,131 @@
1
+ // modules/copy-desk/tests/fixtures/word-docx.mjs — edit a downloaded page .docx
2
+ // the way Word stores an edit (task 1004320 / BV2.TW09, ADR 0341 D12).
3
+ //
4
+ // The Word round-trip tests must not prove the reader against its own writer
5
+ // only. So this fixture re-packs a file the way Word does, with its OWN zip
6
+ // writer (data descriptors after each entry, zero sizes in the local headers,
7
+ // entries in another order), and rewrites a line's paragraph the way Word
8
+ // leaves one after typing: the text split across several runs with their own
9
+ // properties and revision ids, proofing marks between them, smart quotes, and
10
+ // tracked insertions and deletions.
11
+ //
12
+ // Shared by modules/copy-desk/tests/copy_docx.mjs and
13
+ // tests/copy_desk_page_docx.mjs (a fixture, not a test: the runner discovers
14
+ // tests/*.mjs only, not this folder).
15
+
16
+ import zlib from 'node:zlib';
17
+ import { createRequire } from 'node:module';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const docx = require('../../docx.js');
21
+
22
+ // Every entry of a zip, by the module's own reader (reading is not what these
23
+ // tests doubt; the re-pack below is independent of the module's writer).
24
+ export function unzipAll(buf) {
25
+ const z = docx.readZip(buf);
26
+ if (!z.ok) throw new Error(`unzip: ${z.code} ${JSON.stringify(z.detail)}`);
27
+ return new Map([...z.parts].map(([k, v]) => [k, v.toString('utf8')]));
28
+ }
29
+
30
+ // Pack entries the way Word writes a .docx: general-purpose flag bit 3 (sizes
31
+ // and CRC in a data descriptor after the data, zeros in the local header),
32
+ // deflated, in the order given.
33
+ export function wordZip(entries) {
34
+ const out = [];
35
+ const central = [];
36
+ let offset = 0;
37
+ for (const [name, text] of entries) {
38
+ const data = Buffer.from(text, 'utf8');
39
+ const packed = zlib.deflateRawSync(data, { level: 6 });
40
+ const crc = docx.crc32(data);
41
+ const nameBuf = Buffer.from(name, 'utf8');
42
+ const local = Buffer.alloc(30);
43
+ local.writeUInt32LE(0x04034b50, 0);
44
+ local.writeUInt16LE(20, 4);
45
+ local.writeUInt16LE(0x0008 | 0x0800, 6);
46
+ local.writeUInt16LE(8, 8);
47
+ local.writeUInt16LE(0x6000, 10);
48
+ local.writeUInt16LE(0x5b3c, 12);
49
+ local.writeUInt16LE(nameBuf.length, 26);
50
+ const desc = Buffer.alloc(16);
51
+ desc.writeUInt32LE(0x08074b50, 0);
52
+ desc.writeUInt32LE(crc, 4);
53
+ desc.writeUInt32LE(packed.length, 8);
54
+ desc.writeUInt32LE(data.length, 12);
55
+ const c = Buffer.alloc(46);
56
+ c.writeUInt32LE(0x02014b50, 0);
57
+ c.writeUInt16LE(45, 4);
58
+ c.writeUInt16LE(20, 6);
59
+ c.writeUInt16LE(0x0008 | 0x0800, 8);
60
+ c.writeUInt16LE(8, 10);
61
+ c.writeUInt16LE(0x6000, 12);
62
+ c.writeUInt16LE(0x5b3c, 14);
63
+ c.writeUInt32LE(crc, 16);
64
+ c.writeUInt32LE(packed.length, 20);
65
+ c.writeUInt32LE(data.length, 24);
66
+ c.writeUInt16LE(nameBuf.length, 28);
67
+ c.writeUInt32LE(offset, 42);
68
+ out.push(local, nameBuf, packed, desc);
69
+ central.push(c, nameBuf);
70
+ offset += 30 + nameBuf.length + packed.length + 16;
71
+ }
72
+ const cd = Buffer.concat(central);
73
+ const end = Buffer.alloc(22);
74
+ end.writeUInt32LE(0x06054b50, 0);
75
+ end.writeUInt16LE(entries.length, 8);
76
+ end.writeUInt16LE(entries.length, 10);
77
+ end.writeUInt32LE(cd.length, 12);
78
+ end.writeUInt32LE(offset, 16);
79
+ return Buffer.concat([...out, cd, end]);
80
+ }
81
+
82
+ // Re-pack a downloaded file after changing its document part (and optionally
83
+ // other parts), in reverse entry order, the way a save by another program
84
+ // reorders them.
85
+ export function resave(buf, { document, drop = [], parts = {} } = {}) {
86
+ const all = unzipAll(buf);
87
+ if (document) all.set('word/document.xml', document(all.get('word/document.xml')));
88
+ for (const [k, v] of Object.entries(parts)) all.set(k, v);
89
+ for (const k of drop) all.delete(k);
90
+ return wordZip([...all].reverse());
91
+ }
92
+
93
+ // The paragraph that holds a line's content control, as it stands.
94
+ const LINE_PARA = (key) => new RegExp(`<w:p>(?:(?!<w:p>).)*?<w:tag w:val="${key}"/>.*?</w:p>`, 's');
95
+ const LINE_PARA_STYLED = (key) => new RegExp(`<w:p><w:pPr>(?:(?!<w:p>).)*?<w:tag w:val="${key}"/>.*?</w:p>`, 's');
96
+ export function lineParagraph(xml, key) {
97
+ const m = LINE_PARA_STYLED(key).exec(xml) || LINE_PARA(key).exec(xml);
98
+ if (!m) throw new Error(`no paragraph for ${key}`);
99
+ return m[0];
100
+ }
101
+
102
+ // Replace the runs inside a line's content control with Word-shaped runs.
103
+ export function typeInto(xml, key, runsXml) {
104
+ const para = lineParagraph(xml, key);
105
+ const edited = para.replace(/<w:sdtContent>[\s\S]*?<\/w:sdtContent>/, () => `<w:sdtContent>${runsXml}</w:sdtContent>`);
106
+ return xml.replace(para, () => edited);
107
+ }
108
+
109
+ // Word's runs for a typed sentence: each part its own run, some with
110
+ // properties and revision ids, proofing marks between them.
111
+ export function wordRuns(parts) {
112
+ return parts.map((p, i) => {
113
+ const rPr = i % 2 ? '<w:rPr><w:rFonts w:ascii="Calibri"/><w:lang w:val="en-GB"/></w:rPr>' : '';
114
+ const proof = i === 1 ? '<w:proofErr w:type="spellStart"/>' : i === 2 ? '<w:proofErr w:type="spellEnd"/>' : '';
115
+ return `${proof}<w:r w:rsidR="00A1B2C${i}" w:rsidRPr="00D4E5F${i}">${rPr}<w:t xml:space="preserve">${p}</w:t></w:r>`;
116
+ }).join('');
117
+ }
118
+
119
+ // A tracked change: the insertion is text now, the deletion is not.
120
+ export function trackedInsert(text) {
121
+ return `<w:ins w:id="91" w:author="Artist" w:date="2026-09-28T10:00:00Z"><w:r><w:t xml:space="preserve">${text}</w:t></w:r></w:ins>`;
122
+ }
123
+ export function trackedDelete(text) {
124
+ return `<w:del w:id="92" w:author="Artist" w:date="2026-09-28T10:00:00Z"><w:r><w:delText xml:space="preserve">${text}</w:delText></w:r></w:del>`;
125
+ }
126
+
127
+ // Remove every content control, keeping its runs: what an editor that does
128
+ // not keep controls leaves behind (the upload then maps by position).
129
+ export function stripControls(xml) {
130
+ return xml.replace(/<w:sdt><w:sdtPr>[\s\S]*?<\/w:sdtPr><w:sdtContent>([\s\S]*?)<\/w:sdtContent><\/w:sdt>/g, (m, inner) => inner);
131
+ }
@@ -26,6 +26,7 @@ const activity = require('./activity');
26
26
  const mingle = require('./mingle');
27
27
  const pageTweakReads = require('./page-tweak-reads');
28
28
  const pageTweakClaim = require('./page-tweak-claim');
29
+ const pageTweakWrite = require('./page-tweak-write');
29
30
 
30
31
  module.exports = {
31
32
  // --- retired doorway leaks (ADR 0093 §3) ---------------------------------
@@ -118,6 +119,13 @@ module.exports = {
118
119
  // it is page-shaped because the find-or-create has to share the claim's
119
120
  // transaction. See modules/lifecycle/page-tweak-claim.js.
120
121
  webClaimPageTweak: (...a) => pageTweakClaim.webClaimPageTweak(...a),
122
+ // The page draft's autosave and the submit (task 1004319 / BV2.TW08, ADR 0341
123
+ // D4, D11): a holder-guarded description write that skips an unchanged save,
124
+ // and the submit's one transaction (engineer tasks for unplaced lines, the
125
+ // frozen batch, the web claim released, the round back to ready). D11 names
126
+ // updateTaskDescription and releaseClaim; see modules/lifecycle/page-tweak-write.js.
127
+ updateHeldTaskDescription: (...a) => pageTweakWrite.updateHeldTaskDescription(...a),
128
+ submitPageTweak: (...a) => pageTweakWrite.submitPageTweak(...a),
121
129
  listVersions: (...a) => db.listVersions(...a),
122
130
  versionProgress: (...a) => db.versionProgress(...a),
123
131
  recentShippedTasks: (...a) => db.recentShippedTasks(...a),
@@ -37,10 +37,17 @@ const api = require('../../src/module-api');
37
37
  // per task), joined once, rather than a correlated subquery per task:
38
38
  // claims has no full index on task_id, so a per-row LATERAL would scan it
39
39
  // once per round.
40
- async function listPageTweakTasks() {
40
+ //
41
+ // `pageId` narrows it to ONE page's rounds (task 1004319 / TW08): the draft
42
+ // autosave runs this on every debounced save, and a page's rounds are a handful
43
+ // while the instance's grow without bound. The prefix is matched with left(),
44
+ // not LIKE, the page claim's own rule (an `_` in an id is never a wildcard).
45
+ async function listPageTweakTasks({ pageId = null } = {}) {
46
+ const prefix = pageId ? `page-tweak/${pageId}/` : null;
41
47
  const { rows } = await api.pool.query(
42
48
  `WITH pt AS (
43
49
  SELECT t.id FROM tasks t WHERE t.source = 'page-tweak'
50
+ AND ($1::text IS NULL OR left(t.source_ref, length($1::text)) = $1::text)
44
51
  ), web AS (
45
52
  SELECT DISTINCT ON (c.task_id) c.task_id, c.builder_id
46
53
  FROM claims c JOIN pt ON pt.id = c.task_id
@@ -60,7 +67,9 @@ async function listPageTweakTasks() {
60
67
  LEFT JOIN web wc ON wc.task_id = t.id
61
68
  LEFT JOIN builders ab ON ab.id = wc.builder_id
62
69
  WHERE t.source = 'page-tweak'
63
- ORDER BY t.id ASC`
70
+ AND ($1::text IS NULL OR left(t.source_ref, length($1::text)) = $1::text)
71
+ ORDER BY t.id ASC`,
72
+ [prefix]
64
73
  );
65
74
  return rows;
66
75
  }
@@ -0,0 +1,192 @@
1
+ // modules/lifecycle/page-tweak-write.js — the page draft save and the submit
2
+ // (task 1004319 / BV2.TW08, ADR 0341 D4, D7, D11).
3
+ //
4
+ // On the `lifecycle` port because tasks and claims are this module's tables and
5
+ // copy-desk may only cross the wall through a port (ADR 0083, ADR 0093 §2). What
6
+ // the draft and the batch SAY (the fenced blocks, the resolution against the page
7
+ // reading, the engineer task an unplaced line files) is copy-desk's and is handed
8
+ // in as a function; the transaction and the holder check are lifecycle's.
9
+ //
10
+ // BOTH ARE HOLDER-ONLY. The caller must hold the task's open WEB claim
11
+ // (worktree_name NULL and creator_session_id NULL, the claim the page claim
12
+ // takes). The check runs inside the transaction, after the task row is locked
13
+ // FOR UPDATE, the same lock claimTask and the page claim take, so a save cannot
14
+ // interleave with a claim or a release of the same round.
15
+ //
16
+ // AN AUTOSAVE THAT CHANGES NOTHING WRITES NOTHING. The editor autosaves often
17
+ // (it debounces; the server accepts a whole-draft replace). When the composed
18
+ // description equals the stored one, the transaction ends without an UPDATE, so
19
+ // `updated_at` and every reader that sorts by it stay where they were.
20
+ //
21
+ // ADR 0341 D11 names the port entries updateTaskDescription, webClaimTask and
22
+ // releaseClaim. They landed page-shaped, as TW06's webClaimPageTweak did: the
23
+ // description update is holder-guarded (updateHeldTaskDescription, so no caller
24
+ // can rewrite a task it does not hold through the port), and the release runs
25
+ // INSIDE the submit's transaction (submitPageTweak), because the batch, the
26
+ // engineer tasks and the release must commit together or not at all.
27
+ //
28
+ // Outcomes are returned, not thrown, so the route maps each to a named answer:
29
+ // saved | unchanged | submitted
30
+ // not_found no such task
31
+ // wrong_source the task is not of the source the caller named
32
+ // not_held the caller does not hold its open web claim (holder named)
33
+ // refused copy-desk's function refused (its refusal is passed back)
34
+
35
+ 'use strict';
36
+
37
+ const api = require('../../src/module-api');
38
+ const db = require('./db');
39
+
40
+ function activePoolFor(deps) {
41
+ return (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : api.pool;
42
+ }
43
+
44
+ // Lock the task, then read its open claim. Returns { task, claim } or an outcome.
45
+ async function lockHeld(client, { taskId, builderId, source }) {
46
+ const { rows } = await client.query(
47
+ `SELECT id, status, title, description, source, source_ref, version_id, goal_id, touches
48
+ FROM tasks WHERE id = $1 FOR UPDATE`,
49
+ [taskId]
50
+ );
51
+ const task = rows[0];
52
+ if (!task) return { outcome: 'not_found' };
53
+ if (source && task.source !== source) return { outcome: 'wrong_source', task };
54
+ const { rows: held } = await client.query(
55
+ `SELECT c.id, c.builder_id, c.worktree_name, c.creator_session_id, b.github_login, b.display_name
56
+ FROM claims c JOIN builders b ON b.id = c.builder_id
57
+ WHERE c.task_id = $1 AND c.released_at IS NULL
58
+ LIMIT 1`,
59
+ [taskId]
60
+ );
61
+ const claim = held[0] || null;
62
+ const web = !!claim && claim.worktree_name == null && claim.creator_session_id == null;
63
+ if (!claim || !web || String(claim.builder_id) !== String(builderId)) {
64
+ return {
65
+ outcome: 'not_held',
66
+ task,
67
+ holder: claim ? { id: String(claim.builder_id), login: claim.github_login || null, name: claim.display_name || null, writing: web } : null,
68
+ };
69
+ }
70
+ return { task, claim };
71
+ }
72
+
73
+ // updateHeldTaskDescription({ taskId, builderId, source, compose }, deps)
74
+ //
75
+ // compose(task) -> { ok: true, description } | { ok: false, code, detail }
76
+ //
77
+ // The page draft's autosave (PUT /copy-desk/pages/:pageId/draft). Writes the
78
+ // composed description when it differs from the stored one.
79
+ async function updateHeldTaskDescription({ taskId, builderId, source = null, compose }, deps = {}) {
80
+ const client = await activePoolFor(deps).connect();
81
+ try {
82
+ await client.query('BEGIN');
83
+ const held = await lockHeld(client, { taskId, builderId, source });
84
+ if (held.outcome) { await client.query('ROLLBACK'); return held; }
85
+ const r = compose(held.task);
86
+ if (!r || !r.ok) { await client.query('ROLLBACK'); return { outcome: 'refused', refusal: r, task: held.task }; }
87
+ if (r.description === held.task.description) {
88
+ await client.query('ROLLBACK');
89
+ return { outcome: 'unchanged', task: held.task };
90
+ }
91
+ const { rows } = await client.query(
92
+ `UPDATE tasks SET description = $2, updated_at = now() WHERE id = $1
93
+ RETURNING id, status, description, source_ref, updated_at`,
94
+ [taskId, r.description]
95
+ );
96
+ await client.query('COMMIT');
97
+ return { outcome: 'saved', task: rows[0] };
98
+ } catch (err) {
99
+ await client.query('ROLLBACK').catch(() => {});
100
+ throw err;
101
+ } finally {
102
+ client.release();
103
+ }
104
+ }
105
+
106
+ // submitPageTweak({ taskId, builderId, versionId, freeze }, deps)
107
+ //
108
+ // freeze(task) -> { ok: true, unplaced: [{ sourceRef, title, description, touches }],
109
+ // description(unplacedTaskIds) -> string }
110
+ // | { ok: false, code, detail }
111
+ //
112
+ // The submit (POST /copy-desk/pages/:pageId/submit), one transaction:
113
+ // 1. each unplaced rewrite files its engineer task (source page-tweak-unplaced,
114
+ // backlog, engineer), or finds the one its source ref already filed;
115
+ // 2. the description gains the frozen `page-tweak` block naming those tasks;
116
+ // 3. the artist's web claim is RELEASED (outcome 'partial': their part is done
117
+ // and the task goes on) and the task returns to `ready`, so the round reads
118
+ // `submitted` and sits in the /tweak queue (ADR 0341 D7).
119
+ // The released claim is the one the payee rule reads (ADR 0341 D10).
120
+ async function submitPageTweak({ taskId, builderId, versionId = null, freeze }, deps = {}) {
121
+ const createTask = deps.createTask || db.createTask;
122
+ const client = await activePoolFor(deps).connect();
123
+ try {
124
+ await client.query('BEGIN');
125
+ const held = await lockHeld(client, { taskId, builderId, source: 'page-tweak' });
126
+ if (held.outcome) { await client.query('ROLLBACK'); return held; }
127
+ const plan = freeze(held.task);
128
+ if (!plan || !plan.ok) { await client.query('ROLLBACK'); return { outcome: 'refused', refusal: plan, task: held.task }; }
129
+
130
+ // The engineer tasks an earlier submit of this round already filed, in ONE
131
+ // read (a resubmit after a send-back finds them). Only the missing ones go
132
+ // through createTask, the single create chokepoint, one at a time.
133
+ const wanted = plan.unplaced || [];
134
+ const existing = new Map();
135
+ if (wanted.length) {
136
+ const { rows: found } = await client.query(
137
+ 'SELECT id, source_ref FROM tasks WHERE source = $1 AND source_ref = ANY($2::text[])',
138
+ [wanted[0].source, wanted.map((u) => u.sourceRef)]
139
+ );
140
+ for (const f of found) if (!existing.has(f.source_ref)) existing.set(f.source_ref, String(f.id));
141
+ }
142
+ const filed = [];
143
+ for (const u of wanted) {
144
+ if (existing.has(u.sourceRef)) { filed.push({ id: existing.get(u.sourceRef), key: u.key, created: false }); continue; }
145
+ const t = await createTask({
146
+ versionId: versionId != null ? versionId : held.task.version_id,
147
+ // The building version's catch-all goal, declared out loud (BV1.R11),
148
+ // exactly as the round itself and a copy proposal land.
149
+ allowCatchAll: true,
150
+ title: u.title,
151
+ description: u.description,
152
+ // BACKLOG (ADR 0341 D4): an engineer's triage decides when, and the
153
+ // page does not wait for it.
154
+ status: 'backlog',
155
+ kind: 'feature',
156
+ discipline: 'engineer',
157
+ priority: 4,
158
+ touches: u.touches || [],
159
+ source: u.source,
160
+ sourceRef: u.sourceRef,
161
+ createdBy: builderId,
162
+ }, { client });
163
+ filed.push({ id: String(t.id), key: u.key, created: true });
164
+ }
165
+
166
+ const description = plan.description(filed.map((f) => f.id));
167
+ const { rows } = await client.query(
168
+ `UPDATE tasks SET description = $2, status = 'ready', updated_at = now() WHERE id = $1
169
+ RETURNING id, status, description, source_ref, updated_at`,
170
+ [taskId, description]
171
+ );
172
+ await releaseClaim(client, held.claim.id);
173
+ await client.query('COMMIT');
174
+ return { outcome: 'submitted', task: rows[0], unplaced_tasks: filed, released_claim_id: String(held.claim.id) };
175
+ } catch (err) {
176
+ await client.query('ROLLBACK').catch(() => {});
177
+ throw err;
178
+ } finally {
179
+ client.release();
180
+ }
181
+ }
182
+
183
+ // Release one claim inside the caller's transaction. The task's status is the
184
+ // caller's to set (the submit sets `ready` with the same UPDATE as the batch).
185
+ async function releaseClaim(client, claimId, outcome = 'partial') {
186
+ await client.query(
187
+ 'UPDATE claims SET released_at = now(), outcome = $2 WHERE id = $1 AND released_at IS NULL',
188
+ [claimId, outcome]
189
+ );
190
+ }
191
+
192
+ module.exports = { updateHeldTaskDescription, submitPageTweak };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1060",
3
+ "version": "1.19.1062",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1060",
9
+ "version": "1.19.1062",
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.1060",
3
+ "version": "1.19.1062",
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",
@@ -7661,5 +7661,17 @@
7661
7661
  "id": "1004318",
7662
7662
  "text": "An artist can now ask \"what should I tweak next?\" and get one page back, with the reason in plain words, such as \"2 people asked for it\". Pages people asked for come first, then pages whose words changed since their last tweak"
7663
7663
  }
7664
+ ],
7665
+ "1.19.1061": [
7666
+ {
7667
+ "id": "1004319",
7668
+ "text": "An artist's page rewrite is now saved as they type and can be submitted. The draft rests on the page's own tweak task, and saving the same words again changes nothing. Submitting freezes the rewrite for a /tweak session to app"
7669
+ }
7670
+ ],
7671
+ "1.19.1062": [
7672
+ {
7673
+ "id": "1004320",
7674
+ "text": "An artist can now download any page's words as a Word document, work on it offline, and upload it back. The upload goes into their draft, never straight to the site, and only the lines they actually changed in Word are taken,"
7675
+ }
7664
7676
  ]
7665
7677
  }
@@ -42,6 +42,10 @@ const WRITE_VALIDATION_EXEMPT = new Set([
42
42
  'PATCH /me/render', // { widgets: <bool>|null } — renderPrefs.validatePatch
43
43
  'PATCH /me/display-name', // { display_name } — displayName.validate
44
44
  'PATCH /me/profile-nudge', // { dismissed: <bool>|null } — profileNudgePrefs.validatePatch
45
+ // Not a JSON body at all: the raw .docx bytes (task 1004320, ADR 0341 D12), capped by
46
+ // the route's own express.raw parser and read by docx.readUpload, which refuses
47
+ // anything that is not a bounded, readable Word file (not_a_docx / upload_too_large).
48
+ 'POST /copy-desk/pages/:pageId/draft.docx',
45
49
  ]);
46
50
 
47
51
  // KNOWN GAPS, NOT EXEMPTIONS (task 1002551 / BV1.R109b → tracked by task 1002552).
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.1060'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1062'; // 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');