@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
package/docs/file-map.md CHANGED
@@ -124,6 +124,7 @@
124
124
  │ ├── status-ui/ ← CORE-DOMAIN WEB-SURFACE carve (BV1.R84, default-on): the public status dashboard at status.<apex>, moved from public-status/. NO routes/db/port — declares contributes.webSurfaces:[{host:"status.",dir:"public"}], served by the loader web-surface seam (moduleWebSurfaces → serve-internal). CLAUDE.md, module.json, public/{index.html,status.js,style.css,cursors/,snapshots/}.
125
125
  │ ├── hall-ui/ ← CORE-DOMAIN WEB-SURFACE carve (BV1.R85, default-on): the authenticated builders' hall at builders.<apex>, moved from public-builders/ (~13k LOC, the largest). NO routes/db/port — declares contributes.webSurfaces:[{host:"builders.",dir:"public"}] for discovery, but UNLIKE status-ui keeps its DEDICATED serving block in serve-internal (the /builders shim + canonical redirects + citizen/Archon page gates + asset-stamping + hall-widget injection), partitioned out of the generic web-surface loop and served from BUILDERS_DIR. CLAUDE.md, module.json, public/{index.html,builders.js,style.css,work,watch,harbor,gate,pair,settings,ranks,sessions,primer,diagrams,atlas,...}.{html,js},cursors/,chime.wav.
126
126
  │ ├── lifecycle/ ← CORE-DOMAIN carve (BV1.R86, default-on): the build state machine — tasks · claims · versions · done-when · goals · dependencies · claim→ship→grade→credit · github-push + merge-lock + conflict-resolve + publish-reconciler. The LAST, most-coupled carve; registers the `lifecycle` kernel port (createTask, classifyTaskKind, tallyPeerVotes, work-tracking reads). CLAUDE.md, module.json, routes/{tasks,claims,versions,done-when,goals,dependencies,gate-approvals,analytics}.js, lifecycle.js, github-push.js, merge-lock.js, conflict-resolve.js, publish-reconciler.js.
127
+ │ ├── copy-desk/ ← CORE-DOMAIN module (task 1003113 / R02 of goal 1000074, default-on): the COPY half of the design contract — ADR 0081 gave design TOKENS a repo-owned source of truth, text had none. Owns the FLAG (anyone in any role marks a string or a whole surface as needing an artist's attention, with a reason required in BOTH the route and a CHECK constraint) and the QUEUE an artist reads it from. READS R01's committed registry (docs/copy-registry.json, generated by scripts/gds/copy-inventory.js) and never writes it. NO LIVE CMS is the hard non-goal: no column here could hold replacement text, and copy_no_cms.mjs pins that. Key is `copy-desk`, not `copy` — the branding contract already owns a `copy` block, so the bare word reds the "core carries no module code" fitness check. CLAUDE.md, module.json, registry.js, flags.js, routes/copy-desk.js, migrations/copy_desk_001_flags.sql, tests/{copy_flags,copy_queue,copy_no_cms}.mjs. Its hall surface lives with hall-ui (public/copy-desk.{html,js,css}), per the one-key-per-web-surface precedent.
127
128
  │ ├── character-anim/ ← consistent-character generative animation (ADR 0088) — graded painterly frame sets + manifest, the otb-character-review skill. CLAUDE.md, module.json.
128
129
  │ ├── provisioning/ ← stands up + operates a Cloud Bongos instance's own infra (server/DB/domain/DNS/TLS) via the API (ADR 0111); web-tier request+state only, no cloud tokens — a separate control-plane runner drains the intents. Distinct from the `hosting` module (ADR 0092), which brokers compute workloads. CLAUDE.md, module.json.
129
130
  │ └── demo/ ← BV1.R52 acceptance-proof module: one rank-gated write route + one namespaced migration proving the loader mounts a scaffolded module with zero core edits. CLAUDE.md, module.json.
@@ -0,0 +1,15 @@
1
+ # @bongos/core — installed platform dependency (not your project's CLAUDE.md)
2
+
3
+ This file is a placeholder shipped inside the **@bongos/core** package. The core's own
4
+ internal agent notes are intentionally omitted from the published artifact so they cannot
5
+ load into your instance's context.
6
+
7
+ **It does not govern this repository.** Your own root `CLAUDE.md`, your `.claude/`
8
+ directory, and the live Bongos database are the source of truth for how work happens here
9
+ and who may do what.
10
+
11
+ You are seeing this only because an agent opened a file under `node_modules/@bongos/core/`.
12
+ Nothing in here needs to be read to use the platform — run `bongos help` for commands.
13
+
14
+ _To work ON the platform core itself, use the standalone @bongos/core source repository,
15
+ not this vendored copy._
@@ -0,0 +1,245 @@
1
+ // modules/copy-desk/flags.js — the copy_desk_flags domain (task 1003113 / R02 of goal
2
+ // 1000074, the artist's loop).
3
+ //
4
+ // The SOURCING half of the loop: anyone in any role marks a string — or a whole
5
+ // surface — as needing an artist's attention, with a reason. The artist reads
6
+ // the queue built from those rows (registry.js composes it).
7
+ //
8
+ // THE REASON FLOOR IS ENFORCED TWICE, ON PURPOSE. `normalizeFlagPayload` below
9
+ // rejects a thin reason with a named 400, and copy_desk_001_flags.sql rejects it
10
+ // again with a CHECK constraint. That is not belt-and-braces for its own sake:
11
+ // the route layer gives a person a usable error, and the constraint means no
12
+ // future write path — a backfill, a CLI, a later module — can quietly create the
13
+ // reasonless flag this queue exists to not contain.
14
+ //
15
+ // NO LIVE CMS (the goal's hard non-goal). Nothing in this file writes a
16
+ // user-facing string, and no function here returns one to a renderer as
17
+ // authoritative copy. `text_at_flag` is a historical snapshot for a human
18
+ // reading the flag later; the live text always comes from the registry.
19
+ // See modules/copy-desk/tests/copy_no_cms.mjs, which pins that as a test.
20
+ //
21
+ // The PURE seam (normalizeFlagPayload / normalizeResolution) is exported for the
22
+ // DB-free unit gate; the SQL below it is deliberately thin.
23
+
24
+ 'use strict';
25
+
26
+ const { pool } = require('../../src/module-api');
27
+
28
+ // Mirrors of the DB CHECK constraints — enforced here first so a bad payload
29
+ // gets a clean, named 400 instead of a raw constraint violation surfacing as a
30
+ // 500. The DB stays the backstop; this is the error message.
31
+ const SCOPES = Object.freeze(['string', 'surface']);
32
+ const STATUSES = Object.freeze(['open', 'resolved', 'dismissed']);
33
+ const KINDS = Object.freeze(['slop', 'off-voice', 'stale', 'unclear', 'duplicate', 'typo', 'other']);
34
+ // The closing verdicts a person can apply to an open flag. 'open' is not among
35
+ // them — a flag is opened by filing it, not by resolving it to open.
36
+ const CLOSING_STATUSES = Object.freeze(['resolved', 'dismissed']);
37
+
38
+ // The floor, matching copy_desk_flags_reason_chk. Not a quality bar — a quality bar
39
+ // cannot be a number. This rejects "", "bad", "fix" and nothing more.
40
+ const MIN_REASON_CHARS = 10;
41
+ const MAX_REASON_CHARS = 2000;
42
+ const MAX_NOTE_CHARS = 2000;
43
+
44
+ const fail = (code, detail) => ({ ok: false, code, detail: detail ?? null });
45
+
46
+ // Collapse runs of whitespace and trim, so the length floor measures CONTENT and
47
+ // not spacebar. " f i x " is 3 characters of reason, not 15.
48
+ function tidy(raw) {
49
+ return String(raw == null ? '' : raw).replace(/\s+/g, ' ').trim();
50
+ }
51
+
52
+ // Validate + normalize one POSTed flag into the exact column shape insertFlag
53
+ // writes. PURE — no DB, no fs — so the whole contract is unit-testable.
54
+ //
55
+ // `registryIndex` is the loaded registry's index (registry.js). It is REQUIRED:
56
+ // a flag that points at a surface or string the registry does not contain is a
57
+ // dangling pointer, and the artist inherits it. Validating against the live
58
+ // registry rather than a hardcoded list means the generator stays the single
59
+ // place a surface is defined.
60
+ //
61
+ // The SNAPSHOT is taken from the registry here, never from the body — a
62
+ // caller-supplied "this is what it currently says" is a caller-supplied claim,
63
+ // and the snapshot's entire value is that it can be trusted later.
64
+ function normalizeFlagPayload(body, registryIndex, registryHelpers) {
65
+ const b = body || {};
66
+
67
+ const scope = String(b.scope || 'string');
68
+ if (!SCOPES.includes(scope)) return fail('bad_scope', { allowed: SCOPES });
69
+
70
+ const surface = String(b.surface || '');
71
+ if (!surface) return fail('surface_required');
72
+ if (!registryHelpers.hasSurface(registryIndex, surface)) {
73
+ return fail('unknown_surface', { surface, known: [...registryIndex.surfaces] });
74
+ }
75
+
76
+ const kind = String(b.kind || 'other');
77
+ if (!KINDS.includes(kind)) return fail('bad_kind', { allowed: KINDS });
78
+
79
+ const reason = tidy(b.reason);
80
+ if (reason.length < MIN_REASON_CHARS) {
81
+ // The message says WHY, not just that a rule fired — this refusal is read by
82
+ // someone who is trying to help, and "reason too short" without the reason
83
+ // for the reason reads as bureaucracy.
84
+ return fail('reason_too_thin', {
85
+ min_chars: MIN_REASON_CHARS,
86
+ got: reason.length,
87
+ why: 'A flag hands an artist a pointer and a judgement. Without the judgement it costs them a look and tells them nothing — which is worse than not flagging it.',
88
+ });
89
+ }
90
+ if (reason.length > MAX_REASON_CHARS) return fail('reason_too_long', { max_chars: MAX_REASON_CHARS });
91
+
92
+ let stringId = null;
93
+ let snapshot = null;
94
+ if (scope === 'string') {
95
+ stringId = String(b.string_id || '');
96
+ if (!stringId) return fail('string_id_required', { why: "scope 'string' names one registry row; use scope 'surface' to flag a whole surface." });
97
+ snapshot = registryHelpers.snapshotFor(registryIndex, stringId);
98
+ if (!snapshot) return fail('unknown_string_id', { string_id: stringId, at: registryHelpers.REGISTRY_REL });
99
+ // The id encodes its surface, so a body claiming a different one is either a
100
+ // stale client or a confused one. Trust the registry, refuse the mismatch —
101
+ // silently overwriting it would file the flag onto a surface the reporter
102
+ // did not choose, and they would never see where it went.
103
+ if (snapshot.surface && snapshot.surface !== surface) {
104
+ return fail('surface_mismatch', { string_id: stringId, body_surface: surface, registry_surface: snapshot.surface });
105
+ }
106
+ }
107
+
108
+ return {
109
+ ok: true,
110
+ value: {
111
+ scope,
112
+ surface,
113
+ string_id: stringId,
114
+ reason,
115
+ kind,
116
+ text_at_flag: snapshot ? snapshot.text : null,
117
+ file_at_flag: snapshot ? snapshot.file : null,
118
+ line_at_flag: snapshot ? snapshot.line : null,
119
+ registry_schema: snapshot ? snapshot.schema : (registryIndex.schema ?? null),
120
+ },
121
+ };
122
+ }
123
+
124
+ // Validate a close (resolve / dismiss). PURE.
125
+ //
126
+ // A NOTE IS REQUIRED TO CLOSE, for the same reason one is required to open. The
127
+ // symmetry is the point: "someone looked at this and decided" is the record the
128
+ // NEXT person needs when they hit the same sentence, and an unexplained close is
129
+ // indistinguishable from a queue being cleared to make a number go down.
130
+ function normalizeResolution(body) {
131
+ const b = body || {};
132
+ const status = String(b.status || '');
133
+ if (!CLOSING_STATUSES.includes(status)) return fail('bad_status', { allowed: CLOSING_STATUSES });
134
+
135
+ const note = tidy(b.resolution_note);
136
+ if (note.length < MIN_REASON_CHARS) {
137
+ return fail('resolution_note_too_thin', {
138
+ min_chars: MIN_REASON_CHARS,
139
+ got: note.length,
140
+ why: 'Closing a flag is a judgement too. The next person to notice this sentence reads your note instead of re-litigating it.',
141
+ });
142
+ }
143
+ if (note.length > MAX_NOTE_CHARS) return fail('resolution_note_too_long', { max_chars: MAX_NOTE_CHARS });
144
+
145
+ let taskId = null;
146
+ if (b.resolved_task_id !== undefined && b.resolved_task_id !== null && b.resolved_task_id !== '') {
147
+ taskId = Number(b.resolved_task_id);
148
+ if (!Number.isInteger(taskId) || taskId <= 0) return fail('bad_resolved_task_id');
149
+ }
150
+ // A 'resolved' close SHOULD name the task that carried the fix — that link is
151
+ // the ledger proof R03 hangs its proposals on. It is not REQUIRED, because a
152
+ // string can legitimately be fixed by a task filed before this flag existed,
153
+ // and refusing the close would leave a false open flag standing. Absence is
154
+ // recorded (the column stays NULL) rather than being papered over.
155
+ return { ok: true, value: { status, resolution_note: note, resolved_task_id: taskId } };
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // SQL. Thin by design — the rules live above and in the migration's constraints.
160
+ // ---------------------------------------------------------------------------
161
+
162
+ // One list of columns, rendered two ways: qualified for the joined reads, bare
163
+ // for RETURNING (which has no alias in scope). Derived from the single array so
164
+ // the two can never drift — the reads and the write always project the same row
165
+ // shape, which is what lets the hall render an insert response and a queue row
166
+ // with one function.
167
+ const FLAG_COL_NAMES = Object.freeze([
168
+ 'id', 'scope', 'surface', 'string_id', 'reason', 'kind',
169
+ 'text_at_flag', 'file_at_flag', 'line_at_flag', 'registry_schema',
170
+ 'flagged_by', 'flagged_at', 'status', 'resolution_note',
171
+ 'resolved_task_id', 'resolved_by', 'resolved_at',
172
+ ]);
173
+ const FLAG_COLS = FLAG_COL_NAMES.map((c) => `f.${c}`).join(', ');
174
+ const FLAG_COLS_BARE = FLAG_COL_NAMES.join(', ');
175
+
176
+ async function insertFlag(value, builderId) {
177
+ const { rows } = await pool.query(
178
+ `INSERT INTO copy_desk_flags
179
+ (scope, surface, string_id, reason, kind, text_at_flag, file_at_flag, line_at_flag, registry_schema, flagged_by)
180
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
181
+ RETURNING ${FLAG_COLS_BARE}`,
182
+ [value.scope, value.surface, value.string_id, value.reason, value.kind,
183
+ value.text_at_flag, value.file_at_flag, value.line_at_flag, value.registry_schema, builderId]
184
+ );
185
+ return rows[0];
186
+ }
187
+
188
+ // The queue read. `status` defaults to 'open' — the queue is what is OUTSTANDING;
189
+ // history is available by asking for it, never mixed into the working list by
190
+ // default.
191
+ //
192
+ // Joined to builders for the display name: a flag is a judgement by a PERSON,
193
+ // and an artist deciding how much weight to give it needs to know whose it is.
194
+ async function listFlags({ status = 'open', surface = null, stringId = null, limit = 200 } = {}) {
195
+ const where = [];
196
+ const args = [];
197
+ if (status && status !== 'all') { args.push(status); where.push(`f.status = $${args.length}`); }
198
+ if (surface) { args.push(surface); where.push(`f.surface = $${args.length}`); }
199
+ if (stringId) { args.push(stringId); where.push(`f.string_id = $${args.length}`); }
200
+ // Same clamp rule as registry.searchEntries: nonsense in, default out — never
201
+ // a single row, which would read as an almost-empty queue.
202
+ const asked = Number(limit);
203
+ args.push(Math.min(Number.isFinite(asked) && asked > 0 ? Math.floor(asked) : 200, 500));
204
+
205
+ const { rows } = await pool.query(
206
+ `SELECT ${FLAG_COLS},
207
+ b.github_login AS flagged_by_login,
208
+ b.display_name AS flagged_by_name,
209
+ r.github_login AS resolved_by_login
210
+ FROM copy_desk_flags f
211
+ JOIN builders b ON b.id = f.flagged_by
212
+ LEFT JOIN builders r ON r.id = f.resolved_by
213
+ ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
214
+ ORDER BY f.status = 'open' DESC, f.flagged_at DESC
215
+ LIMIT $${args.length}`,
216
+ args
217
+ );
218
+ return rows;
219
+ }
220
+
221
+ async function getFlag(id) {
222
+ const { rows } = await pool.query(`SELECT ${FLAG_COLS} FROM copy_desk_flags f WHERE f.id = $1`, [id]);
223
+ return rows[0] || null;
224
+ }
225
+
226
+ // Close a flag. Guarded on `status = 'open'` IN THE UPDATE so two people closing
227
+ // the same flag at once cannot both win — the second gets zero rows and a named
228
+ // 409, rather than silently overwriting the first person's verdict and note.
229
+ async function closeFlag(id, value, builderId) {
230
+ const { rows } = await pool.query(
231
+ `UPDATE copy_desk_flags
232
+ SET status = $2, resolution_note = $3, resolved_task_id = $4,
233
+ resolved_by = $5, resolved_at = now()
234
+ WHERE id = $1 AND status = 'open'
235
+ RETURNING ${FLAG_COLS_BARE}`,
236
+ [id, value.status, value.resolution_note, value.resolved_task_id, builderId]
237
+ );
238
+ return rows[0] || null;
239
+ }
240
+
241
+ module.exports = {
242
+ SCOPES, STATUSES, KINDS, CLOSING_STATUSES, MIN_REASON_CHARS, MAX_REASON_CHARS, MAX_NOTE_CHARS,
243
+ tidy, normalizeFlagPayload, normalizeResolution,
244
+ insertFlag, listFlags, getFlag, closeFlag,
245
+ };
@@ -0,0 +1,128 @@
1
+ -- copy_desk_001_flags.sql — the COPY FLAG: a first-class request that an artist look
2
+ -- at one string, or at a whole surface (task 1003113 / R02 of goal 1000074).
3
+ --
4
+ -- WHAT A FLAG IS. R01 built the inventory (docs/copy-registry.json): every
5
+ -- user-facing string, with a stable id and the render path that shows it. An
6
+ -- inventory answers "what does the product say". It cannot answer "what is
7
+ -- WRONG with what the product says" — that is a judgement someone has to make
8
+ -- and record. This table is that record, and it is the sourcing half of the
9
+ -- artist's loop: anyone in any role files a flag, and the artist reads a queue
10
+ -- built from them.
11
+ --
12
+ -- THE REASON IS REQUIRED, IN THE SCHEMA. A flag with no reason is worthless —
13
+ -- it hands the artist a pointer and no judgement, which is strictly worse than
14
+ -- not flagging, because it still costs them a look. The board's objection flow
15
+ -- makes the same call for the same reason (ADR 0175), so the floor lives in a
16
+ -- CHECK constraint here and not only in the route: a flag with an empty or
17
+ -- one-word reason must be impossible to store, not merely hard to POST.
18
+ --
19
+ -- WHAT A FLAG POINTS AT, AND WHY THE SNAPSHOT COLUMNS EXIST. `string_id` is the
20
+ -- registry's stable id — a content hash over (surface, text, render path), so it
21
+ -- is stable across a file MOVE but NOT across an edit to the text itself. That is
22
+ -- the right behaviour (editing the copy is what resolves the flag) but it means a
23
+ -- flag can outlive the string it names. So every flag also carries a SNAPSHOT of
24
+ -- what was flagged: the text, the file and the line as they read at flag time.
25
+ -- Without that, a resolved flag is unreadable a month later ("what did this say
26
+ -- before?"), and an open flag whose string has since changed looks like a
27
+ -- pointer into nothing rather than what it is — a judgement about a specific
28
+ -- sentence that someone has since edited. The queue reads the snapshot beside
29
+ -- the live registry and reports when they diverge.
30
+ --
31
+ -- NO LIVE CMS — the goal's hard non-goal, and this schema is a load-bearing part
32
+ -- of holding it. There is deliberately NO column here that a renderer would ever
33
+ -- read. A flag carries a judgement and a pointer; it can never carry replacement
34
+ -- text, because a "proposed new string" column is one join away from being a
35
+ -- production copy source, and the ledger rule (every change is a claimed, graded,
36
+ -- shipped task) would be dead. R03 attaches proposals to TASKS; `resolved_task_id`
37
+ -- below is the seam it lands on. Do not add a `new_text` column here.
38
+ --
39
+ -- Module migrations are ADDITIVE and NAMESPACED (ADR 0083 §Decision #5): every
40
+ -- object is copy_desk_-prefixed, there is no down path, and the filename is
41
+ -- copy_desk_-prefixed (ADR 0108 §5) so its stem never collides across repos.
42
+ -- Idempotent: safe to re-run.
43
+
44
+ BEGIN;
45
+
46
+ CREATE TABLE IF NOT EXISTS copy_desk_flags (
47
+ id bigserial PRIMARY KEY,
48
+
49
+ -- WHAT is flagged. 'string' names one registry row; 'surface' flags a whole
50
+ -- surface ("the landing page reads like a brochure") — a judgement that has no
51
+ -- single string to hang on, and would otherwise be filed as a misleading flag
52
+ -- against whichever line the reporter happened to be looking at.
53
+ scope text NOT NULL,
54
+ -- The registry surface id ('builders-hall', 'status-dashboard', 'landing').
55
+ -- Text, not an FK: surfaces live in the generated registry, not in a table, and
56
+ -- a scope-wall-crossing lookup table would put the file and the DB in a race.
57
+ -- The route validates it against the live registry before insert.
58
+ surface text NOT NULL,
59
+ -- The registry's stable string id. NULL exactly when scope = 'surface'.
60
+ string_id text,
61
+
62
+ -- The judgement. REQUIRED — see the header.
63
+ reason text NOT NULL,
64
+ -- What KIND of wrong, so the artist can sort a long queue by the work it
65
+ -- implies (a typo and an off-voice paragraph are different sittings). Text +
66
+ -- CHECK rather than an enum TYPE: widening is a one-line constraint change in
67
+ -- a later copy_* migration.
68
+ kind text NOT NULL DEFAULT 'other',
69
+
70
+ -- The snapshot: what this flag was ABOUT, frozen at flag time. See the header.
71
+ text_at_flag text,
72
+ file_at_flag text,
73
+ line_at_flag integer,
74
+ -- Which registry schema produced the snapshot, so a future schema change can
75
+ -- tell old rows from new ones instead of guessing.
76
+ registry_schema integer,
77
+
78
+ flagged_by bigint NOT NULL REFERENCES builders(id),
79
+ flagged_at timestamptz NOT NULL DEFAULT now(),
80
+
81
+ -- open → the queue. resolved → the copy was changed. dismissed → looked at,
82
+ -- judged fine. Dismissal is a FIRST-CLASS outcome, not a soft delete: "an
83
+ -- artist read this and disagreed" is the answer to the next person who flags
84
+ -- the same sentence, and deleting the row throws that away.
85
+ status text NOT NULL DEFAULT 'open',
86
+ resolution_note text,
87
+ -- The task that carried the fix. The seam R03 lands its proposals on, and the
88
+ -- proof that a copy change went through the ledger rather than around it.
89
+ resolved_task_id bigint REFERENCES tasks(id),
90
+ resolved_by bigint REFERENCES builders(id),
91
+ resolved_at timestamptz,
92
+
93
+ CONSTRAINT copy_desk_flags_scope_chk CHECK (scope IN ('string', 'surface')),
94
+ CONSTRAINT copy_desk_flags_status_chk CHECK (status IN ('open', 'resolved', 'dismissed')),
95
+ CONSTRAINT copy_desk_flags_kind_chk CHECK (kind IN ('slop', 'off-voice', 'stale', 'unclear', 'duplicate', 'typo', 'other')),
96
+ -- The required-reason floor, in the schema (see the header). 10 characters of
97
+ -- non-whitespace is not a quality bar — it is the "" / "bad" / "fix" floor.
98
+ CONSTRAINT copy_desk_flags_reason_chk CHECK (length(btrim(reason)) >= 10),
99
+ -- string_id is present exactly when scope = 'string'. Both halves matter: a
100
+ -- string-scoped flag with no id points nowhere, and a surface-scoped flag
101
+ -- WITH one would be silently narrower than it claims to be.
102
+ CONSTRAINT copy_desk_flags_target_chk CHECK ((scope = 'string') = (string_id IS NOT NULL)),
103
+ -- A closed flag is stamped and an open one is not — in both directions, so
104
+ -- "resolved" can never mean "resolved by nobody at no time".
105
+ CONSTRAINT copy_desk_flags_closed_chk CHECK ((status = 'open') = (resolved_at IS NULL AND resolved_by IS NULL))
106
+ );
107
+
108
+ -- The queue read: open flags, newest first, optionally per surface.
109
+ CREATE INDEX IF NOT EXISTS copy_desk_flags_status_surface_idx
110
+ ON copy_desk_flags (status, surface, flagged_at DESC);
111
+
112
+ -- "Is this string already flagged?" — asked once per rendered row when the hall
113
+ -- marks up the registry, so it is worth an index of its own.
114
+ CREATE INDEX IF NOT EXISTS copy_desk_flags_string_open_idx
115
+ ON copy_desk_flags (string_id) WHERE status = 'open';
116
+
117
+ -- One OPEN flag per (target, builder). Two builders flagging the same sentence
118
+ -- is SIGNAL and stays allowed — it is the cheapest agreement measure the queue
119
+ -- has. The same builder filing twice is a double-click or a forgotten flag, and
120
+ -- the artist should not pay for either. Closed rows are exempt, so a sentence
121
+ -- that regresses can be flagged again by the same person.
122
+ CREATE UNIQUE INDEX IF NOT EXISTS copy_desk_flags_one_open_per_target_idx
123
+ ON copy_desk_flags (flagged_by, surface, COALESCE(string_id, '')) WHERE status = 'open';
124
+
125
+ INSERT INTO schema_migrations (version) VALUES ('copy_desk_001_flags')
126
+ ON CONFLICT (version) DO NOTHING;
127
+
128
+ COMMIT;
@@ -0,0 +1,11 @@
1
+ {
2
+ "key": "copy-desk",
3
+ "title": "Copy desk (the artist's loop over user-facing text)",
4
+ "description": "The COPY half of the design contract (goal 1000074, the artist's loop). Named for the editorial desk copy passes over before it is published — which is exactly the job, and which keeps the module key off the generic word 'copy' that the branding contract already owns (branding.copy.tagline/hallName/notFound). ADR 0081 made the repo the source of truth for design TOKENS and component surfaces; there was no equivalent for user-facing TEXT. This module owns that half: the FLAG — anyone, in any role, marks a string or a whole surface as needing an artist's attention, with a required reason — and the QUEUE an artist reads it from. The inventory a flag points at is the committed registry docs/copy-registry.json, generated by scripts/gds/copy-inventory.js (R01); this module reads it and never writes it. NO LIVE CMS is the goal's hard non-goal: nothing here can change a rendered string. A flag is a work request; the fix ships as a claimed, graded GDS task like every other change. default-on — every instance shows a person words, so every instance can flag them.",
5
+ "coreVersion": "^1.8.0",
6
+ "default": true,
7
+ "maintenance": { "status": "core-maintained" },
8
+ "contributes": { "routes": ["copy-desk"], "migrations": true },
9
+ "provides": [],
10
+ "consumes": []
11
+ }