@bongos/core 1.19.249 → 1.19.251

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 (38) hide show
  1. package/.bongos-core.json +110 -30
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +10 -0
  4. package/clients/bongos-client/index.cjs +10 -0
  5. package/clients/bongos-client/index.d.ts +14 -0
  6. package/clients/bongos-client/index.mjs +10 -0
  7. package/docs/adr/0178-the-copy-desk-flag-and-queue.md +131 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +257 -3
  10. package/docs/api-reference.md +10 -1
  11. package/docs/copy-inventory.md +1430 -0
  12. package/docs/copy-registry.json +13293 -0
  13. package/docs/file-map.md +1 -0
  14. package/modules/copy-desk/CLAUDE.md +15 -0
  15. package/modules/copy-desk/flags.js +245 -0
  16. package/modules/copy-desk/migrations/copy_desk_001_flags.sql +128 -0
  17. package/modules/copy-desk/module.json +11 -0
  18. package/modules/copy-desk/registry.js +317 -0
  19. package/modules/copy-desk/routes/copy-desk.js +272 -0
  20. package/modules/copy-desk/tests/copy_flags.mjs +246 -0
  21. package/modules/copy-desk/tests/copy_no_cms.mjs +104 -0
  22. package/modules/copy-desk/tests/copy_queue.mjs +257 -0
  23. package/modules/dev-box/app/src/vendor/bongos-client.cjs +10 -0
  24. package/modules/hall-ui/public/copy-desk.css +119 -0
  25. package/modules/hall-ui/public/copy-desk.html +139 -0
  26. package/modules/hall-ui/public/copy-desk.js +352 -0
  27. package/modules/hall-ui/public/shell.js +9 -0
  28. package/package-lock.json +2 -2
  29. package/package.json +1 -1
  30. package/scripts/gds/publish-manifest.js +9 -0
  31. package/scripts/gds/run-unit-tests.js +11 -1
  32. package/src/bongos/module-scope-map.js +17 -0
  33. package/src/bongos/serve-internal.js +11 -1
  34. package/src/module-api.js +5 -1
  35. package/tests/copy_desk_flags_db.mjs +250 -0
  36. package/tests/hall_nav.mjs +7 -1
  37. package/tests/module_loader.mjs +5 -2
  38. package/tests/publish_manifest.mjs +82 -0
@@ -62,6 +62,7 @@ const PUBLISH_ALLOWLIST = [
62
62
  'modules/autonomy/',
63
63
  'modules/security/',
64
64
  'modules/government/', // the same machinery under its post-ADR-0174 name (goal 1000068, task 1003003). Added in lockstep with the protected-surfaces.json entry: publish_manifest asserts every PROTECTED_GLOB is either topology-excluded or publishable, so registering the glob as protected WITHOUT this line default-denies the module from the published core — the open-source methodology would silently lose its permission machinery at the next release.
65
+ 'modules/copy-desk/', // the artist's loop over user-facing TEXT (goal 1000074, ADR 0178). default:true, so it MUST be here — and note the ASYMMETRY that makes omitting it worse than useless: its hall surface ships regardless, because modules/hall-ui/ is allowlisted as one subtree. A released core carrying the page and the nav entry but not the routes behind them puts a tab in every instance's hall whose every call 404s. The module and its page are only ever added or removed together; tests/publish_manifest.mjs holds that pair.
65
66
  'modules/hall-ui/',
66
67
  'modules/status-ui/',
67
68
  'modules/dev-box/',
@@ -104,6 +105,14 @@ const PUBLISH_ALLOWLIST = [
104
105
  'docs/branding-contract.md',
105
106
  'docs/branding-fill-prompt.md',
106
107
  'docs/design-contract.md',
108
+ // RUNTIME DATA that happens to live under docs/, not documentation (ADR 0178).
109
+ // modules/copy-desk reads the registry on EVERY queue load, and nothing on a
110
+ // deployed box regenerates it — copy-inventory.js is a commit-it generator run
111
+ // against a source tree, which an instance does not have. Omit these and a
112
+ // released instance answers 503 registry_missing forever. They sit under docs/
113
+ // only because that is where their generator has always written them.
114
+ 'docs/copy-registry.json',
115
+ 'docs/copy-inventory.md', // the human twin, generated in the same pass
107
116
  'docs/file-map.md',
108
117
  'docs/repo-map.md',
109
118
  'docs/handoff-template.md', // the portable session-handoff format (methodology)
@@ -73,7 +73,17 @@ const INTEGRATION = new Set([
73
73
  // platform-server — network + package build + a spawned server, run by its own
74
74
  // .github/workflows/consumer-layout-boot.yml, not this DB-free gate (task 2142).
75
75
  'consumer_layout_boot',
76
- 'claim_gate_open_blocker', 'claim_gate_rebase', 'clawback_confirmed_backlog', 'discord_autojoin', 'discord_oauth',
76
+ 'claim_gate_open_blocker', 'claim_gate_rebase', 'clawback_confirmed_backlog',
77
+ // task 1003113: the copy_desk_flags schema half. The module's own tests are
78
+ // DB-free and cover the pure layer; the rules that MATTER here live in the
79
+ // schema — a CHECK-constraint reason floor whose whole argument is that no
80
+ // future write path evades it, a PARTIAL unique index whose partiality is the
81
+ // design, and a close guarded on the open status so two closers cannot both
82
+ // win. None of those are testable without a real transaction engine.
83
+ // (Prose here avoids single-quoted tokens: the pairing guard below parses this
84
+ // set out of the source, so a quoted word in a comment reads as an entry.)
85
+ 'copy_desk_flags_db',
86
+ 'discord_autojoin', 'discord_oauth',
77
87
  // RENAMED by ADR 0174 (goal 1000068) — these entries said governance_* until task
78
88
  // 1003091. A curated set keyed by a stem that no longer exists silently stops
79
89
  // covering its file: both of these dropped OUT of the integration set and INTO the
@@ -216,6 +216,23 @@ const MODULE_GLOBS = {
216
216
  'modules/game/', 'src/preview-server.js',
217
217
  ],
218
218
  'art-pipeline': ['modules/art-pipeline/'],
219
+ // --- the COPY half of the design contract: the artist's loop over user-facing
220
+ // TEXT (goal 1000074). ADR 0081 gave design TOKENS a repo-owned source of
221
+ // truth; text had none. R01 built the inventory (the copy-inventory
222
+ // generator + its committed registry under docs/), R02 the flag + queue on
223
+ // top of it. The generator travels with the domain but stays under
224
+ // scripts/gds/ — the memory/ideas precedent (a command travels with its
225
+ // domain, design 02§1). The generated artifacts are listed as exact paths
226
+ // so a copy task's touches[] can name them without claiming all of docs/.
227
+ // KEY IS 'copy-desk', NOT 'copy': the branding contract already owns a
228
+ // `copy` block (branding.copy.tagline/hallName/notFound), so the bare word
229
+ // collides with kernel code and reds the "core carries no module code"
230
+ // fitness check on the English word alone.
231
+ 'copy-desk': [
232
+ 'modules/copy-desk/',
233
+ 'scripts/gds/copy-inventory.js',
234
+ 'docs/copy-registry.json', 'docs/copy-inventory.md',
235
+ ],
219
236
  // --- the declarative agent registry (goal 1000038, ADR 0083 default:false):
220
237
  // agent definitions reconciled at deploy into agents_definitions + a per-fire
221
238
  // agents_runs ledger. Carved to modules/agents/ at foundation time, so the
@@ -922,8 +922,18 @@ function mountInternalSurfaces(app) {
922
922
  // thetes redirected exactly that reader to /builders/not-ready. Owner's call
923
923
  // (task 1002710) — the RANK floor is gone; sign-in is unchanged.
924
924
  // The link-hiding in shell.js is only cosmetic; these are the real gates.
925
+ // task 1003113: copy-desk joins this tier rather than a higher one. Filing a
926
+ // flag is open to every rank by design (see the nav comment in shell.js), so
927
+ // the page's only real gate is sign-in — the writes behind it self-gate, and
928
+ // closing someone else's flag is checked server-side at metic-or-author.
929
+ //
930
+ // NB the assignment below must stay `const NAME_RE = /…/;` with NOTHING between
931
+ // the `=` and the literal: tests/hall_page_gate_map.mjs reconstructs the whole
932
+ // URL→gate map by parsing this file statically, and a comment in that gap drops
933
+ // the regex out of its map — which reads as "every page on this tier is
934
+ // ungated". It fails loudly by design; keep prose above the const.
925
935
  const SIGNEDIN_BUILDERS_PAGE_RE =
926
- /^\/builders\/(?:work|pair|settings|atlas|modules|goals|roadmap|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
936
+ /^\/builders\/(?:work|pair|settings|atlas|modules|goals|roadmap|copy-desk|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
927
937
  // The diagram image assets under /builders/diagrams/ ride the same tier as the
928
938
  // page that embeds them.
929
939
  const SIGNEDIN_BUILDERS_PREFIX_RE = /^\/builders\/diagrams\//;
package/src/module-api.js CHANGED
@@ -925,8 +925,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
925
925
  // landed since 1.19.247 with no explicit bump. run 32198208766. (task 1002620)
926
926
  // 1.19.249 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
927
927
  // landed since 1.19.248 with no explicit bump. run 32199293372. (task 1002620)
928
+ // 1.19.250 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
929
+ // landed since 1.19.249 with no explicit bump. run 32203357427. (task 1002620)
930
+ // 1.19.251 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
931
+ // landed since 1.19.250 with no explicit bump. run 32204008770. (task 1002620)
928
932
  // ---------------------------------------------------------------------------
929
- const CORE_VERSION = '1.19.249'; // CI auto-patch carrier (ADR 0161); see changelog above
933
+ const CORE_VERSION = '1.19.251'; // CI auto-patch carrier (ADR 0161); see changelog above
930
934
 
931
935
  // A namespaced logger so a module's log lines are attributable + consistent.
932
936
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,250 @@
1
+ // tests/copy_desk_flags_db.mjs — the copy_desk_flags schema + SQL against a REAL
2
+ // Postgres (task 1003113 / R02 of goal 1000074).
3
+ //
4
+ // WHY THIS EXISTS SEPARATELY FROM modules/copy-desk/tests/. Those three files are
5
+ // DB-free by contract and cover the PURE layer — the validators, the queue
6
+ // composition, the no-CMS shape. They cannot cover the half of this feature that
7
+ // lives in the schema, and that half is where the load-bearing rules are:
8
+ //
9
+ // * the REASON FLOOR is a CHECK constraint, not just a route check. The whole
10
+ // argument for putting it there is that no future write path can get around
11
+ // it — which is a claim about Postgres, and can only be tested against
12
+ // Postgres.
13
+ // * the ONE-OPEN-FLAG-PER-(builder, target) rule is a PARTIAL unique index. Its
14
+ // partiality is the design (two builders flagging one sentence is signal; the
15
+ // same builder twice is a double-click; and a closed flag must not block a
16
+ // re-flag when the copy regresses). A fake client cannot exercise that.
17
+ // * the CLOSE IS GUARDED on `status='open'` inside the UPDATE so two people
18
+ // closing at once cannot both win. That is a race, and a race needs a real
19
+ // transaction engine to demonstrate.
20
+ // * the FK on resolved_task_id is what turns a typo'd task number into a named
21
+ // 400 instead of an opaque 500.
22
+ //
23
+ // It SELF-SKIPS (exit 0) when no Postgres is reachable, so it is safe on a bare
24
+ // worktree, and it is listed in run-unit-tests.js's INTEGRATION set so the
25
+ // default DB-free lane never runs it.
26
+ //
27
+ // Run: DATABASE_URL=postgres://... node tests/copy_desk_flags_db.mjs
28
+ //
29
+ // It creates its OWN minimal `builders`/`tasks` stand-ins if they are absent, so
30
+ // it works against a throwaway container as well as a full migrated database, and
31
+ // it cleans up every row it writes.
32
+
33
+ import { createRequire } from 'node:module';
34
+ import { strict as assert } from 'node:assert';
35
+ import fs from 'node:fs';
36
+ import path from 'node:path';
37
+ import url from 'node:url';
38
+
39
+ const require = createRequire(import.meta.url);
40
+ const { Pool } = require('pg');
41
+
42
+ const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..');
43
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
44
+
45
+ try {
46
+ await pool.query('SELECT 1');
47
+ } catch (e) {
48
+ console.log(`copy_desk_flags_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}). Set DATABASE_URL to run.`);
49
+ await pool.end().catch(() => {});
50
+ process.exit(0);
51
+ }
52
+
53
+ let passed = 0;
54
+ let failed = 0;
55
+ async function test(name, fn) {
56
+ try { await fn(); passed++; console.log(` ok ${name}`); }
57
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
58
+ }
59
+
60
+ // --- fixtures -------------------------------------------------------------
61
+ // Minimal stand-ins ONLY if the real tables are absent (throwaway container).
62
+ // Against a fully migrated DB these are no-ops and the real tables are used.
63
+ await pool.query(`
64
+ CREATE TABLE IF NOT EXISTS schema_migrations (version text PRIMARY KEY);
65
+ CREATE TABLE IF NOT EXISTS builders (id bigserial PRIMARY KEY, github_login text, display_name text);
66
+ CREATE TABLE IF NOT EXISTS tasks (id bigserial PRIMARY KEY);
67
+ `);
68
+
69
+ // Two throwaway builders + one task, at ids far above any real row.
70
+ const A = 900000001;
71
+ const B = 900000002;
72
+ const T = 900000003;
73
+ await pool.query('INSERT INTO builders (id, github_login, display_name) VALUES ($1,$2,$3),($4,$5,$6) ON CONFLICT (id) DO NOTHING',
74
+ [A, 'cdtest-a', 'Copy Desk Test A', B, 'cdtest-b', 'Copy Desk Test B']);
75
+ await pool.query('INSERT INTO tasks (id) VALUES ($1) ON CONFLICT (id) DO NOTHING', [T]);
76
+
77
+ // Apply the migration verbatim — this also proves it is idempotent, since a
78
+ // migrated DB has already run it.
79
+ const MIGRATION = fs.readFileSync(path.join(ROOT, 'modules/copy-desk/migrations/copy_desk_001_flags.sql'), 'utf8');
80
+ await pool.query(MIGRATION);
81
+
82
+ // Every row this test writes carries a surface nothing else uses, so cleanup is
83
+ // exact and it can never delete a real flag.
84
+ const SURF = '__copy_desk_test__';
85
+ const clean = () => pool.query('DELETE FROM copy_desk_flags WHERE surface = $1', [SURF]);
86
+ await clean();
87
+
88
+ const REASON = 'Reads like a stack trace, not a sentence a person would say.';
89
+ // `string_id` uses an `in` check, not `??` — an explicit null is the POINT of
90
+ // several cases below, and `??` would silently substitute the default.
91
+ function ins(o = {}) {
92
+ return pool.query(
93
+ `INSERT INTO copy_desk_flags
94
+ (scope, surface, string_id, reason, kind, text_at_flag, file_at_flag, line_at_flag, registry_schema, flagged_by)
95
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`,
96
+ [o.scope ?? 'string', SURF, ('string_id' in o ? o.string_id : 'abc123'), o.reason ?? REASON,
97
+ o.kind ?? 'other', 'Could not save.', 'a.js', 1, 1, o.by ?? A]
98
+ );
99
+ }
100
+
101
+ console.log('copy_desk_flags — the constraints hold in Postgres, not just in JS');
102
+
103
+ await test('the migration is idempotent (re-running is clean)', async () => {
104
+ await pool.query(MIGRATION);
105
+ });
106
+
107
+ await test('a well-formed flag inserts and defaults to open + unstamped', async () => {
108
+ const { rows } = await ins();
109
+ assert.equal(rows[0].status, 'open');
110
+ assert.equal(rows[0].resolved_at, null);
111
+ assert.equal(rows[0].resolved_by, null);
112
+ assert.ok(rows[0].flagged_at);
113
+ });
114
+
115
+ await test('THE REASON FLOOR fires in the DB, not only in the route', async () => {
116
+ await assert.rejects(() => ins({ reason: 'bad', string_id: 'r1' }), (e) => e.code === '23514');
117
+ });
118
+
119
+ await test('a whitespace-only reason is refused (btrim in the CHECK)', async () => {
120
+ await assert.rejects(() => ins({ reason: ' ', string_id: 'r2' }), (e) => e.code === '23514');
121
+ });
122
+
123
+ await test("scope 'string' with a NULL string_id is refused — it points nowhere", async () => {
124
+ await assert.rejects(() => ins({ scope: 'string', string_id: null }), (e) => e.code === '23514');
125
+ });
126
+
127
+ await test("scope 'surface' WITH a string_id is refused — it would be silently narrower than it claims", async () => {
128
+ await assert.rejects(() => ins({ scope: 'surface', string_id: 'x1' }), (e) => e.code === '23514');
129
+ });
130
+
131
+ await test("scope 'surface' with a NULL string_id inserts", async () => {
132
+ const { rows } = await ins({ scope: 'surface', string_id: null, by: B });
133
+ assert.equal(rows[0].scope, 'surface');
134
+ assert.equal(rows[0].string_id, null);
135
+ });
136
+
137
+ await test('an unknown kind is refused', async () => {
138
+ await assert.rejects(() => ins({ kind: 'vibes', string_id: 'k1' }), (e) => e.code === '23514');
139
+ });
140
+
141
+ await test('THE PARTIAL UNIQUE INDEX: the same builder on the same target twice → 23505', async () => {
142
+ await ins({ string_id: 'dup-target', by: A });
143
+ await assert.rejects(() => ins({ string_id: 'dup-target', by: A }), (e) => e.code === '23505');
144
+ });
145
+
146
+ await test('a DIFFERENT builder may flag the same target — that agreement is signal', async () => {
147
+ const { rows } = await ins({ string_id: 'dup-target', by: B });
148
+ assert.ok(rows[0].id);
149
+ });
150
+
151
+ await test('the guarded close stamps exactly one row', async () => {
152
+ const { rows: [f] } = await ins({ string_id: 'close-me', by: A });
153
+ const upd = await pool.query(
154
+ `UPDATE copy_desk_flags SET status=$2, resolution_note=$3, resolved_task_id=$4, resolved_by=$5, resolved_at=now()
155
+ WHERE id=$1 AND status='open' RETURNING *`,
156
+ [f.id, 'resolved', 'Rewritten in the ship below.', T, B]);
157
+ assert.equal(upd.rowCount, 1);
158
+ assert.equal(upd.rows[0].status, 'resolved');
159
+ assert.equal(String(upd.rows[0].resolved_task_id), String(T));
160
+ assert.ok(upd.rows[0].resolved_at);
161
+ });
162
+
163
+ await test('A SECOND CLOSE LOSES THE RACE — 0 rows, never an overwrite of the first verdict', async () => {
164
+ const { rows: [f] } = await ins({ string_id: 'race', by: A });
165
+ const first = await pool.query(
166
+ `UPDATE copy_desk_flags SET status='resolved', resolution_note='first verdict stands', resolved_by=$2, resolved_at=now()
167
+ WHERE id=$1 AND status='open' RETURNING *`, [f.id, A]);
168
+ const second = await pool.query(
169
+ `UPDATE copy_desk_flags SET status='dismissed', resolution_note='second must not win', resolved_by=$2, resolved_at=now()
170
+ WHERE id=$1 AND status='open' RETURNING *`, [f.id, B]);
171
+ assert.equal(first.rowCount, 1);
172
+ assert.equal(second.rowCount, 0);
173
+ const { rows } = await pool.query('SELECT resolution_note FROM copy_desk_flags WHERE id=$1', [f.id]);
174
+ assert.equal(rows[0].resolution_note, 'first verdict stands');
175
+ });
176
+
177
+ await test('after a close, the SAME builder may flag that target again (the index is partial)', async () => {
178
+ const { rows } = await ins({ string_id: 'close-me', by: A });
179
+ assert.ok(rows[0].id, 'a sentence that regresses must be re-flaggable by the person who caught it before');
180
+ });
181
+
182
+ await test('a closed row with no resolved_at is refused — "resolved by nobody at no time" is impossible', async () => {
183
+ const { rows: [f] } = await ins({ string_id: 'halfclosed', by: A });
184
+ await assert.rejects(
185
+ () => pool.query("UPDATE copy_desk_flags SET status='resolved' WHERE id=$1", [f.id]),
186
+ (e) => e.code === '23514');
187
+ });
188
+
189
+ await test('THE FK: a nonexistent resolved_task_id raises 23503 — the route\'s named 400', async () => {
190
+ const { rows: [f] } = await ins({ string_id: 'fk-test', by: A });
191
+ await assert.rejects(
192
+ () => pool.query(
193
+ `UPDATE copy_desk_flags SET status='resolved', resolution_note='names a task that does not exist',
194
+ resolved_by=$2, resolved_at=now(), resolved_task_id=999999999 WHERE id=$1`, [f.id, A]),
195
+ (e) => e.code === '23503');
196
+ });
197
+
198
+ console.log("\nthe module's own SQL functions (flags.js), not hand-written queries");
199
+
200
+ // Point the doorway's shared pool at THIS connection so flags.js runs its real
201
+ // queries against the test database.
202
+ const api = require(path.join(ROOT, 'src/module-api.js'));
203
+ api.pool.query = (...a) => pool.query(...a);
204
+ const flagsLib = require(path.join(ROOT, 'modules/copy-desk/flags.js'));
205
+
206
+ await test('insertFlag writes and projects the full row shape', async () => {
207
+ const row = await flagsLib.insertFlag({
208
+ scope: 'string', surface: SURF, string_id: 'lib-1', reason: 'Says nothing a reader can act on.',
209
+ kind: 'unclear', text_at_flag: 'Enter.', file_at_flag: 'i.html', line_at_flag: 4, registry_schema: 1,
210
+ }, A);
211
+ assert.equal(row.status, 'open');
212
+ assert.equal(row.kind, 'unclear');
213
+ assert.equal(row.string_id, 'lib-1');
214
+ assert.ok('resolution_note' in row, 'the RETURNING projection matches the read projection');
215
+ });
216
+
217
+ await test('listFlags resolves the flagger join and sorts open ahead of closed', async () => {
218
+ const rows = await flagsLib.listFlags({ status: 'all', surface: SURF });
219
+ assert.ok(rows.length > 0);
220
+ assert.ok(rows.some((r) => r.flagged_by_login === 'cdtest-a'), 'the builders join resolves a display name');
221
+ const firstClosed = rows.findIndex((r) => r.status !== 'open');
222
+ if (firstClosed !== -1) {
223
+ assert.ok(rows.slice(firstClosed).every((r) => r.status !== 'open'), 'open rows sort ahead of closed ones');
224
+ }
225
+ });
226
+
227
+ await test('listFlags honours the status + surface filters', async () => {
228
+ const open = await flagsLib.listFlags({ status: 'open', surface: SURF });
229
+ assert.ok(open.length > 0);
230
+ assert.ok(open.every((r) => r.status === 'open' && r.surface === SURF));
231
+ });
232
+
233
+ await test('closeFlag returns null on a second call — the route answers 409 from that', async () => {
234
+ const row = await flagsLib.insertFlag({
235
+ scope: 'string', surface: SURF, string_id: 'lib-close', reason: 'Off voice for the front door.',
236
+ kind: 'off-voice', text_at_flag: 'x', file_at_flag: 'i.html', line_at_flag: 9, registry_schema: 1,
237
+ }, B);
238
+ const first = await flagsLib.closeFlag(row.id, { status: 'dismissed', resolution_note: 'Deliberate house style.', resolved_task_id: null }, A);
239
+ assert.ok(first, 'the first close returns the row');
240
+ const second = await flagsLib.closeFlag(row.id, { status: 'resolved', resolution_note: 'must not win', resolved_task_id: null }, B);
241
+ assert.equal(second, null);
242
+ });
243
+
244
+ // --- cleanup --------------------------------------------------------------
245
+ await clean();
246
+ await pool.query('DELETE FROM builders WHERE id = ANY($1)', [[A, B]]).catch(() => {});
247
+
248
+ console.log(`\n${passed} passed, ${failed} failed`);
249
+ await pool.end();
250
+ process.exit(failed ? 1 : 0);
@@ -131,7 +131,13 @@ test('sidebar groups read by audience, in order', () => {
131
131
  // naming the top rank was wrong), then → 'Government' by ADR 0174: a header
132
132
  // reading "Archon" over items a Metic can open would misdescribe the ladder.
133
133
  assert.deepEqual(nav.map((g) => g.label), ['', 'You', 'Community', 'Docs', 'Government']);
134
- assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'work'],
134
+ // task 1003113 (R02 of goal 1000074): 'copy-desk' joins the unlabeled group,
135
+ // after Tasks. It belongs with the project's own work and not in Docs — it is a
136
+ // QUEUE somebody acts on, not reference material — and not in You or Community,
137
+ // whose subject is a person rather than the product. Deliberate addition to
138
+ // this oracle: the group is pinned exactly so a new tab in the hall's most
139
+ // valuable nav real estate is a reviewed decision, not a drift.
140
+ assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'work', 'copy-desk'],
135
141
  'the unlabeled group is the project itself (task 1002425 order)');
136
142
  assert.deepEqual(nav[1].items.map((i) => i.id), ['standing', 'profile'], 'You = your own numbers');
137
143
  // People (task 1002577): the /scouting directory graduated into the hall.
@@ -173,7 +173,10 @@ test('the live tree discovers the extracted modules cleanly (BV1.R43/R48/R47/R49
173
173
  // configurable roles/permissions module; and agents (ADR 0083 / goal 1000038) the
174
174
  // declarative agent registry — the first module to ship default:false, which is
175
175
  // why it is DISCOVERED here (discovery is enablement-independent) while staying
176
- // off in enabledModules(). All are discovered; none has validation
176
+ // off in enabledModules(). And copy-desk (task 1003113 / R02 of goal 1000074) —
177
+ // the copy half of the design contract: the flag + the artist's queue over
178
+ // user-facing text, default-on because every instance shows a person words.
179
+ // All are discovered; none has validation
177
180
  // errors or coreVersion mismatches. Order is the readdir sort.
178
181
  //
179
182
  // NB this is the CORE package tree: game / art-pipeline / character-anim / demo are
@@ -197,7 +200,7 @@ test('the live tree discovers the extracted modules cleanly (BV1.R43/R48/R47/R49
197
200
  // That is precisely how the agents regression reached an open PR (task 1002485).
198
201
  loader._reset();
199
202
  const snap = loader.loaderSnapshot();
200
- assert.deepEqual(snap.loaded, ['agents', 'autonomy', 'builder-settings', 'dev-box', 'discord', 'economy', 'government', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'memory', 'onboarding', 'platform-identity', 'provisioning', 'security', 'sessions', 'status-ui']);
203
+ assert.deepEqual(snap.loaded, ['agents', 'autonomy', 'builder-settings', 'copy-desk', 'dev-box', 'discord', 'economy', 'government', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'memory', 'onboarding', 'platform-identity', 'provisioning', 'security', 'sessions', 'status-ui']);
201
204
  assert.deepEqual(snap.errors, []);
202
205
  assert.deepEqual(snap.skipped, []);
203
206
  loader._reset();
@@ -15,7 +15,15 @@
15
15
 
16
16
  import { strict as assert } from 'node:assert';
17
17
  import { createRequire } from 'node:module';
18
+ // fs/path/ROOT serve the disk-DERIVED guards at the bottom of this file (task
19
+ // 1003132). They must read the tree rather than a list: an enumerated assertion
20
+ // would miss the next unclassified module exactly as the enumerated allowlist
21
+ // missed the one that prompted them.
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import url from 'node:url';
18
25
 
26
+ const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..');
19
27
  const require = createRequire(import.meta.url);
20
28
  const m = require('../scripts/gds/publish-manifest.js');
21
29
  const {
@@ -179,6 +187,80 @@ t('every PROTECTED_GLOB is either topology-excluded or publishable (the methodol
179
187
  assert.ok(isPublishable(g), `protected surface "${g}" is neither denied nor publishable — re-decide the carve-out`);
180
188
  }
181
189
  });
190
+ // ── the publish question, asked of the tree itself (task 1003132) ───────────────────
191
+ //
192
+ // THE GAP THESE CLOSE. modules/copy-desk/ was designed, built, tested against a real
193
+ // Postgres, graded, merged and very nearly RELEASED while publishing nothing. Not one
194
+ // of the 429 unit tests, 20+ fitness checks, or the adversarial grader asks the publish
195
+ // question, and the assertions above enumerate representative paths — so a brand-new
196
+ // module key appears in no list and is simply invisible.
197
+ //
198
+ // The failure is worse than "the feature does not ship", because it is ASYMMETRIC: the
199
+ // module's hall page ships anyway (modules/hall-ui/ is one allowlisted subtree), so a
200
+ // release delivers a nav tab and a page whose every API call 404s.
201
+ //
202
+ // Both guards DERIVE from disk. A list here would rot the same way the allowlist did.
203
+ //
204
+ // Modules deliberately not published, on the record. An entry here is a decision
205
+ // someone made, not a gap someone forgot — and every entry needs the task or idea that
206
+ // records the decision, so this can never become a quiet parking lot.
207
+ const KNOWN_UNPUBLISHED_MODULES = new Map([
208
+ // Same gap as copy-desk, found by the guard below the moment it was written. LATENT
209
+ // rather than live only because agents is default:false — the day a released instance
210
+ // enables it, the loader MODULE_NOT_FOUNDs, which is exactly what the provisioning
211
+ // (task 2135) and platform-identity (task 1002235) allowlist entries exist to prevent.
212
+ // Left for goal 1000038 rather than fixed here: deciding what the published core
213
+ // contains for the agents module is that goal's call, not a drive-by from a copy-desk
214
+ // task. Filed as idea 1000797 with the one-line fix.
215
+ ['agents', 'idea 1000797'],
216
+ ]);
217
+
218
+ t('every module on disk is either publishable or deliberately unpublished (a new module cannot be silently invisible)', () => {
219
+ const modulesDir = path.join(ROOT, 'modules');
220
+ const keys = fs.readdirSync(modulesDir, { withFileTypes: true })
221
+ .filter((d) => d.isDirectory() && fs.existsSync(path.join(modulesDir, d.name, 'module.json')))
222
+ .map((d) => d.name);
223
+ assert.ok(keys.length > 5, `sanity: only ${keys.length} modules found on disk — the scan is broken, not the tree`);
224
+
225
+ const unclassified = keys.filter((k) => {
226
+ const manifest = `modules/${k}/module.json`;
227
+ return !isPublishable(manifest) && !matchesDeny(manifest) && !KNOWN_UNPUBLISHED_MODULES.has(k);
228
+ });
229
+ assert.deepEqual(unclassified, [],
230
+ 'these modules are in neither the allowlist nor a deny list, so they publish NOTHING. Add each to PUBLISH_ALLOWLIST, deny it on the record, or register it in KNOWN_UNPUBLISHED_MODULES with the task that decided it.');
231
+ });
232
+
233
+ t('every KNOWN_UNPUBLISHED_MODULES entry names a real module and a real decision', () => {
234
+ // Two ways this exception list rots: an entry outlives the module (and silently
235
+ // exempts nothing), or an entry outlives the fix (and silently exempts something
236
+ // already fixed). Both make the guard above weaker than it looks.
237
+ for (const [key, ref] of KNOWN_UNPUBLISHED_MODULES) {
238
+ assert.ok(fs.existsSync(path.join(ROOT, 'modules', key, 'module.json')),
239
+ `KNOWN_UNPUBLISHED_MODULES names "${key}", which is not a module on disk — drop the entry`);
240
+ assert.ok(/\b(task|idea)\s+\d+/.test(ref),
241
+ `KNOWN_UNPUBLISHED_MODULES["${key}"] must cite the task or idea that decided it, got "${ref}"`);
242
+ assert.equal(isPublishable(`modules/${key}/module.json`), false,
243
+ `"${key}" is now publishable — delete its KNOWN_UNPUBLISHED_MODULES entry so the guard covers it again`);
244
+ }
245
+ });
246
+
247
+ t('a hall page named for a module ships only if that module ships (never a tab whose API 404s)', () => {
248
+ const hallDir = path.join(ROOT, 'modules/hall-ui/public');
249
+ const modulesDir = path.join(ROOT, 'modules');
250
+ if (!fs.existsSync(hallDir)) return;
251
+ const orphans = [];
252
+ for (const f of fs.readdirSync(hallDir)) {
253
+ if (!f.endsWith('.html')) continue;
254
+ const key = f.replace(/\.html$/, '');
255
+ // Only pages that NAME a module are paired — the hall's own pages (work, goals,
256
+ // settings…) have no module and are not the subject of this rule.
257
+ if (!fs.existsSync(path.join(modulesDir, key, 'module.json'))) continue;
258
+ if (!isPublishable(`modules/${key}/module.json`)) orphans.push(f);
259
+ }
260
+ assert.deepEqual(orphans, [],
261
+ 'these hall pages would ship to instances whose backing module does not — a nav tab and a page whose every call 404s. Publish the module, or stop publishing the page.');
262
+ });
263
+
182
264
  t('the topology subset of the protected surfaces is never publishable', () => {
183
265
  for (const g of PROTECTED_TOPOLOGY_EXCLUDE) {
184
266
  assert.equal(isPublishable(g), false, `topology surface "${g}" must not publish`);