@bongos/core 1.19.492 → 1.19.493

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,278 @@
1
+ // modules/copy-desk/proposals.js — the PROPOSAL: an artist's replacement wording
2
+ // for one string, carried as a patch on a GDS task (task 1003114 / R03 of goal
3
+ // 1000074, the artist's loop).
4
+ //
5
+ // WHY THIS FILE EXISTS. R02 gave the artist a way to say "this sentence is
6
+ // wrong" (the flag). It deliberately gave them no way to say what it should say
7
+ // instead, because the obvious place to put that — a column next to the flag —
8
+ // is the exact shape goal 1000074 forbids: "NO LIVE CMS ... never a write path
9
+ // that bypasses the ledger" (and ADR 0178 §3, which pins that copy_desk_flags
10
+ // has no column that could hold replacement text and must never gain one).
11
+ //
12
+ // THE SHAPE THAT SATISFIES BOTH HALVES, and it is borrowed rather than invented:
13
+ // ADR 0081 already solved this problem for the DESIGN half. A tool authors, an
14
+ // adapter EXPORTS its output into the repo as a committed diff under a claimed
15
+ // task, and the repo stays the source of truth (modules/ui-design/adapters/*,
16
+ // `adapter.export()`). This is that contract for TEXT. The artist is the tool;
17
+ // the copy desk is where they author; the proposal is the export. So a proposal
18
+ // is not a row this module keeps — it is a PATCH, rendered into the description
19
+ // of a task, which is the ledger the methodology already requires every change
20
+ // to pass through. Nothing renders from it; scripts/gds/copy-apply.js is the
21
+ // only reader, and it runs under a claim.
22
+ //
23
+ // WHERE THE PATCH LIVES, AND WHY IT IS A FENCED BLOCK. It has to be readable by
24
+ // a person reviewing the task (the whole point is that the change is arguable
25
+ // before it lands) and parseable by the applier. A fenced ```copy-proposal block
26
+ // in the task description is both, renders as itself in the hall, and survives a
27
+ // person editing the prose around it. It is deliberately NOT a database column
28
+ // anywhere: a column is one join away from being read as copy, which is the
29
+ // erosion ADR 0178 describes.
30
+ //
31
+ // PURE — no DB, no fs, no clock. Both callers import it: the route
32
+ // (routes/copy-desk.js) composes, the applier (scripts/gds/copy-apply.js)
33
+ // parses, and neither owns a second copy of the format.
34
+
35
+ 'use strict';
36
+
37
+ // The patch format's version. The applier refuses anything else BY NAME rather
38
+ // than doing its best with a shape it does not know — a half-understood patch
39
+ // edits the wrong characters of a real file.
40
+ const PROPOSAL_SCHEMA = 1;
41
+
42
+ // The fence tag. Chosen to be unmistakable in a task description and impossible
43
+ // to produce by accident.
44
+ const FENCE = 'copy-proposal';
45
+
46
+ // The same judgement floor R02 put on a flag's reason, for the same reason
47
+ // (ADR 0178 §2): a proposal with no argument hands a reviewer a diff and no way
48
+ // to decide it, which costs them more than not proposing. The DB CHECK that
49
+ // backs the flag's floor has no equivalent here because there is no row — the
50
+ // floor is enforced at the one write vector, which is this file's caller.
51
+ const MIN_WHY_CHARS = 10;
52
+ const MAX_WHY_CHARS = 2000;
53
+
54
+ // 400 is the registry's own upper bound on what counts as one string
55
+ // (`looksLikeCopy` in scripts/gds/copy-inventory.js). Proposing something longer
56
+ // than the inventory can hold would produce a patch that lands and then vanishes
57
+ // from the next registry — a change that silently un-inventories itself.
58
+ const MAX_TEXT_CHARS = 400;
59
+
60
+ // The interpolation hole the inventory writes for `${...}`. A proposal must keep
61
+ // exactly as many as the current text has, in the same order: the applier maps
62
+ // the k-th hole back to the k-th expression it found in the source. Reordering
63
+ // or dropping one is a code change, not a copy change, and is refused by name.
64
+ const PLACEHOLDER = '{…}';
65
+
66
+ const fail = (code, detail) => ({ ok: false, code, detail: detail ?? null });
67
+
68
+ // Collapse whitespace runs and trim — the same normalization the inventory
69
+ // applies when it records a string, so a proposal is measured and stored in the
70
+ // form it will actually be compared against.
71
+ function tidy(raw) {
72
+ return String(raw == null ? '' : raw).replace(/\s+/g, ' ').trim();
73
+ }
74
+
75
+ function placeholderCount(text) {
76
+ return String(text || '').split(PLACEHOLDER).length - 1;
77
+ }
78
+
79
+ // Validate one POSTed proposal into the patch the task will carry.
80
+ //
81
+ // `entry` is the registry occurrence being changed, and it is REQUIRED and
82
+ // server-side. Every fact about the TARGET — surface, file, line, render path,
83
+ // and the current wording — is copied from it and never from the request body,
84
+ // which is R02's rule for a flag's snapshot (ADR 0178 §6) and matters more here:
85
+ // a caller-supplied "current text" would let a proposal describe a change to a
86
+ // sentence that does not exist, and the applier would then be editing a file on
87
+ // the strength of a claim nobody checked.
88
+ //
89
+ // PURE. The caller resolves `entry` from the loaded registry first.
90
+ function normalizeProposalPayload({ body = {}, entry, builderId = null } = {}) {
91
+ if (!entry || !entry.file || !entry.text) return fail('unknown_target');
92
+
93
+ const proposed = tidy(body.proposed_text);
94
+ const why = tidy(body.why);
95
+ const current = tidy(entry.text);
96
+
97
+ if (!proposed) {
98
+ return fail('proposed_text_missing', {
99
+ what_this_means: 'A proposal is a replacement wording. Without one there is nothing to land — file a flag instead if you only want to mark this as wrong.',
100
+ });
101
+ }
102
+ if (proposed.length > MAX_TEXT_CHARS) {
103
+ return fail('proposed_text_too_long', { max: MAX_TEXT_CHARS, was: proposed.length });
104
+ }
105
+ if (proposed === current) {
106
+ return fail('proposed_text_unchanged', {
107
+ what_this_means: 'The proposed wording is the current wording. Nothing would change.',
108
+ });
109
+ }
110
+ if (why.length < MIN_WHY_CHARS) {
111
+ return fail('why_too_thin', {
112
+ min: MIN_WHY_CHARS,
113
+ what_this_means: 'A reviewer has to be able to decide this without asking you. Say what is wrong with the current wording and what the new one does better — the same floor a flag’s reason carries.',
114
+ });
115
+ }
116
+ if (why.length > MAX_WHY_CHARS) {
117
+ return fail('why_too_long', { max: MAX_WHY_CHARS, was: why.length });
118
+ }
119
+
120
+ // The placeholder arithmetic. This is the one rule that makes the patch
121
+ // MECHANICALLY applicable rather than a note for an engineer: the applier
122
+ // re-injects the source's own interpolations into the holes, so the count has
123
+ // to match and the order has to be the author's intent.
124
+ const want = placeholderCount(current);
125
+ const got = placeholderCount(proposed);
126
+ if (want !== got) {
127
+ return fail('placeholder_mismatch', {
128
+ expected: want,
129
+ got,
130
+ placeholder: PLACEHOLDER,
131
+ what_this_means: want === 0
132
+ ? `This string has no ${PLACEHOLDER} holes, so the new wording must not introduce any.`
133
+ : `This string is assembled at render time and has ${want} ${PLACEHOLDER} hole(s) where a value is dropped in. Keep all ${want}, in the order the sentence needs them — moving or removing one is a code change, not a copy change.`,
134
+ });
135
+ }
136
+
137
+ return {
138
+ ok: true,
139
+ patch: {
140
+ schema: PROPOSAL_SCHEMA,
141
+ string_id: entry.id,
142
+ surface: entry.surface,
143
+ file: entry.file,
144
+ line: entry.line,
145
+ origin: entry.origin,
146
+ current,
147
+ proposed,
148
+ why,
149
+ proposed_by: builderId ?? null,
150
+ },
151
+ };
152
+ }
153
+
154
+ // Render the patch as the fenced block that goes in the task's description.
155
+ // JSON with a stable key order and two-space indent: a reviewer reads it, and a
156
+ // diff of two proposals against the same string lines up.
157
+ function composeProposalBlock(patch) {
158
+ const ordered = {
159
+ schema: patch.schema,
160
+ string_id: patch.string_id,
161
+ surface: patch.surface,
162
+ file: patch.file,
163
+ line: patch.line,
164
+ origin: patch.origin,
165
+ current: patch.current,
166
+ proposed: patch.proposed,
167
+ };
168
+ return ['```' + FENCE, JSON.stringify(ordered, null, 2), '```'].join('\n');
169
+ }
170
+
171
+ // The reverse. Returns the FIRST block in the text: a task description carries
172
+ // exactly one proposal, and if someone has pasted a second the applier must not
173
+ // silently pick one — so a second block is a named refusal, not a coin flip.
174
+ function parseProposalBlock(markdown) {
175
+ const src = String(markdown || '');
176
+ const re = new RegExp('```' + FENCE + '\\s*\\n([\\s\\S]*?)\\n```', 'g');
177
+ const found = [...src.matchAll(re)];
178
+ if (!found.length) return fail('no_proposal_block', { fence: FENCE });
179
+ if (found.length > 1) {
180
+ return fail('multiple_proposal_blocks', {
181
+ count: found.length,
182
+ what_this_means: 'This task carries more than one proposal. Split them: one task changes one string, so the grade and the diff are about one decision.',
183
+ });
184
+ }
185
+ let patch;
186
+ try {
187
+ patch = JSON.parse(found[0][1]);
188
+ } catch (err) {
189
+ return fail('proposal_block_unreadable', { reason: err.message });
190
+ }
191
+ if (Number(patch && patch.schema) !== PROPOSAL_SCHEMA) {
192
+ return fail('proposal_schema_unknown', { expected: PROPOSAL_SCHEMA, got: patch && patch.schema });
193
+ }
194
+ for (const field of ['string_id', 'surface', 'file', 'origin', 'current', 'proposed']) {
195
+ if (typeof patch[field] !== 'string' || !patch[field]) {
196
+ return fail('proposal_field_missing', { field });
197
+ }
198
+ }
199
+ if (!Number.isInteger(patch.line) || patch.line < 1) {
200
+ return fail('proposal_field_missing', { field: 'line' });
201
+ }
202
+ return { ok: true, patch };
203
+ }
204
+
205
+ // The task a proposal becomes. Title names the surface and quotes enough of the
206
+ // current wording to be recognizable in a board list; the body carries the
207
+ // artist's argument, the patch, and the one command that lands it.
208
+ function proposalTaskTitle(patch) {
209
+ const snippet = patch.current.length > 60 ? `${patch.current.slice(0, 57)}…` : patch.current;
210
+ return `Copy: reword “${snippet}” on ${patch.surface}`;
211
+ }
212
+
213
+ // The task's own instructions. They must be RUNNABLE AS WRITTEN — a placeholder
214
+ // a claimer has to substitute is a step to get wrong, and this is the one
215
+ // handoff between the person who wrote the words and the person who lands them.
216
+ //
217
+ // The task cannot know its own id at the moment its description is composed, so
218
+ // the command takes none: `copy-apply.js` with no argument acts on the task you
219
+ // are HOLDING. That is not a convenience, it is the rule — a copy change lands
220
+ // under a claim, and a claim already names a task.
221
+ function proposalTaskBody(patch) {
222
+ return [
223
+ `An artist proposed new wording for one string on the \`${patch.surface}\` surface.`,
224
+ '',
225
+ '**Why**',
226
+ '',
227
+ patch.why,
228
+ '',
229
+ '**The change**',
230
+ '',
231
+ `- current: ${JSON.stringify(patch.current)}`,
232
+ `- proposed: ${JSON.stringify(patch.proposed)}`,
233
+ `- where: \`${patch.file}:${patch.line}\` (${patch.origin})`,
234
+ '',
235
+ '**How it lands**',
236
+ '',
237
+ 'This is a proposal, not a change — nothing has been written. Claim this task and run:',
238
+ '',
239
+ '```bash',
240
+ 'node scripts/gds/copy-apply.js',
241
+ '```',
242
+ '',
243
+ 'That re-derives the string’s position in the working tree, replaces exactly those characters, regenerates the copy registry and prints the diff for review. Then ship it like any other change — the grader sees the real diff.',
244
+ '',
245
+ 'If the applier refuses, it says why in one line; a refusal means the file moved on under the proposal and a person should look, not that the wording was wrong.',
246
+ '',
247
+ composeProposalBlock(patch),
248
+ ].join('\n');
249
+ }
250
+
251
+ // The (source, source_ref) pair the create-vector idempotency read keys on
252
+ // (db.findTaskBySourceRef). Keyed on the string AND the proposed wording, so a
253
+ // double-submitted proposal resolves to the task it already made, while a
254
+ // genuinely different second attempt at the same string is a new task — which is
255
+ // right: two people can disagree about the fix, and the board should hold both.
256
+ function proposalSourceRef(patch) {
257
+ let hash = 5381;
258
+ const material = `${patch.string_id}${patch.file}${patch.line}${patch.proposed}`;
259
+ for (let i = 0; i < material.length; i += 1) hash = (((hash << 5) + hash) ^ material.charCodeAt(i)) >>> 0;
260
+ return `copy-proposal-${patch.string_id}-${hash.toString(36)}`;
261
+ }
262
+
263
+ module.exports = {
264
+ PROPOSAL_SCHEMA,
265
+ FENCE,
266
+ PLACEHOLDER,
267
+ MIN_WHY_CHARS,
268
+ MAX_WHY_CHARS,
269
+ MAX_TEXT_CHARS,
270
+ tidy,
271
+ placeholderCount,
272
+ normalizeProposalPayload,
273
+ composeProposalBlock,
274
+ parseProposalBlock,
275
+ proposalTaskTitle,
276
+ proposalTaskBody,
277
+ proposalSourceRef,
278
+ };
@@ -1,12 +1,14 @@
1
1
  // modules/copy-desk/routes/copy.js — the copy-flag surface (task 1003113 / R02 of
2
2
  // goal 1000074, the artist's loop).
3
3
  //
4
- // Three jobs:
4
+ // Four jobs (the fourth added by task 1003114 / R03 — see below):
5
5
  // GET /copy-desk/queue — the artist's working list: open flags with
6
6
  // live registry state attached, plus the
7
7
  // deterministic duplicate candidates.
8
8
  // POST /copy-desk/flags — file a flag. ANY signed-in builder, any role.
9
9
  // POST /copy-desk/flags/:id/close — resolve or dismiss one, with a note.
10
+ // POST /copy-desk/proposals — propose new wording. Creates a TASK carrying
11
+ // the patch; writes no file and stores no copy.
10
12
  //
11
13
  // THE RANK SHAPE, AND WHY IT IS ASYMMETRIC:
12
14
  //
@@ -24,10 +26,14 @@
24
26
  // The refusal names both paths rather than dead-ending on a bare 403.
25
27
  //
26
28
  // WHAT DOES NOT EXIST HERE, AND MUST NOT: any route that writes a user-facing
27
- // string. NO LIVE CMS is the goal's hard non-goal (task 1003114 / R03 makes the
28
- // editing affordance produce a TASK, not a write). A flag is a work request.
29
- // modules/copy-desk/tests/copy_no_cms.mjs pins that by asserting this router's write
30
- // surface stays exactly these two verbs.
29
+ // string. NO LIVE CMS is the goal's hard non-goal, and R03 kept it while adding
30
+ // the editing affordance the goal also asked for — by making the affordance
31
+ // produce a TASK carrying a patch, never a write. A flag is a work request; a
32
+ // proposal is a work request with a suggested fix attached. Neither reaches a
33
+ // reader. modules/copy-desk/tests/copy_no_cms.mjs pins that by asserting this
34
+ // router's write surface stays exactly these three verbs, that none of them
35
+ // touches the filesystem, and that the one place a proposed wording is allowed
36
+ // to rest is the GDS ledger.
31
37
  //
32
38
  // Same idioms as modules/security/routes/*.js: doorway-only requires, res.fail
33
39
  // codes, factory export mounted from module.json contributes.
@@ -38,9 +44,16 @@ const express = require('express');
38
44
  const api = require('../../../src/module-api');
39
45
  const flags = require('../flags');
40
46
  const registry = require('../registry');
47
+ const proposals = require('../proposals');
41
48
 
42
49
  const { validateOrRespond } = api;
43
50
 
51
+ // The doorway's namespaced logger, not console.error (task 1003114). Same seam
52
+ // builder-settings / dev-box / ideas / government already use: a module's log
53
+ // lines come out structured and attributable, and an error object logged as
54
+ // `{ err }` keeps its stack instead of being stringified into one line.
55
+ const log = api.logger('copy-desk');
56
+
44
57
  // The write schemas (ADR 0118): strict by default, so an unknown or typo'd field
45
58
  // is REJECTED rather than silently dropped. Built once at module scope — the
46
59
  // [...flags.KINDS] spreads mean a renamed export crashes at boot with a named
@@ -61,6 +74,19 @@ const CLOSE_BODY_SCHEMA = Object.freeze({
61
74
  resolution_note: { required: true, type: 'string', maxLength: flags.MAX_NOTE_CHARS, minLength: 1 },
62
75
  resolved_task_id: { type: 'integer', min: 1 },
63
76
  });
77
+ // The proposal body (task 1003114 / R03). `file` and `line` are OPTIONAL and only
78
+ // disambiguate a string that appears at several sites — see the route. Notice
79
+ // what is absent and must stay absent: nothing here names a destination. The
80
+ // target is resolved from the registry by `string_id`, so a caller can propose a
81
+ // change to a string the product actually shows and to nothing else.
82
+ const PROPOSAL_BODY_SCHEMA = Object.freeze({
83
+ string_id: { required: true, type: 'string', maxLength: 64, minLength: 1 },
84
+ proposed_text: { required: true, type: 'string', maxLength: proposals.MAX_TEXT_CHARS, minLength: 1 },
85
+ why: { required: true, type: 'string', maxLength: proposals.MAX_WHY_CHARS, minLength: 1 },
86
+ file: { type: 'string', maxLength: 400 },
87
+ line: { type: 'integer', min: 1 },
88
+ flag_id: { type: 'integer', min: 1 },
89
+ });
64
90
 
65
91
  // Load the registry or answer the named "no registry" state. Returns null when
66
92
  // it has already responded, so every handler is `if (!reg) return;`.
@@ -127,7 +153,7 @@ module.exports = function buildCopyDeskRouter() {
127
153
  res.set('Cache-Control', 'no-store');
128
154
  res.json(queue);
129
155
  } catch (err) {
130
- console.error('[gds] GET /copy-desk/queue', err);
156
+ log.error({ err }, 'GET /copy-desk/queue failed');
131
157
  if (!res.headersSent) res.fail('copy_queue_failed', 500);
132
158
  }
133
159
  });
@@ -163,7 +189,7 @@ module.exports = function buildCopyDeskRouter() {
163
189
  res.set('Cache-Control', 'no-store');
164
190
  res.json(result);
165
191
  } catch (err) {
166
- console.error('[gds] GET /copy-desk/strings', err);
192
+ log.error({ err }, 'GET /copy-desk/strings failed');
167
193
  if (!res.headersSent) res.fail('copy_search_failed', 500);
168
194
  }
169
195
  });
@@ -200,7 +226,7 @@ module.exports = function buildCopyDeskRouter() {
200
226
  }
201
227
  res.status(201).json({ ok: true, flag });
202
228
  } catch (err) {
203
- console.error('[gds] POST /copy-desk/flags', err);
229
+ log.error({ err }, 'POST /copy-desk/flags failed');
204
230
  if (!res.headersSent) res.fail('copy_flag_failed', 500);
205
231
  }
206
232
  });
@@ -263,10 +289,148 @@ module.exports = function buildCopyDeskRouter() {
263
289
 
264
290
  res.json({ ok: true, flag });
265
291
  } catch (err) {
266
- console.error('[gds] POST /copy-desk/flags/:id/close', err);
292
+ log.error({ err }, 'POST /copy-desk/flags/:id/close failed');
267
293
  if (!res.headersSent) res.fail('copy_flag_close_failed', 500);
268
294
  }
269
295
  });
270
296
 
297
+ // -------------------------------------------------------------------------
298
+ // POST /copy-desk/proposals — an artist proposes new wording, and it becomes a
299
+ // TASK (task 1003114 / R03 of goal 1000074).
300
+ //
301
+ // Body: { string_id, proposed_text, why, file?, line?, flag_id? }
302
+ // rank: any signed-in builder — the same floor as filing, for the same reason
303
+ // (an artist is a craft, not a rank; see the header).
304
+ //
305
+ // THIS IS THE ROUTE THE NON-GOAL IS ABOUT, so read what it does NOT do. It
306
+ // writes no file. It stores no wording in any table of this module — ADR 0178
307
+ // §3 pins that copy_desk_flags can never hold replacement text, and this
308
+ // route's whole design is what makes that pin survivable rather than merely
309
+ // strict. What it produces is a TASK carrying a patch, in the ledger every
310
+ // change already has to pass through, which nothing renders from. The wording
311
+ // reaches a person only after somebody claims that task, runs
312
+ // `node scripts/gds/copy-apply.js --task <id>`, and ships the resulting diff
313
+ // through the normal pipeline with the grader reading it.
314
+ //
315
+ // The shape is borrowed, not invented: it is `adapter.export()` from the
316
+ // design half (ADR 0081) applied to text. See modules/copy-desk/proposals.js.
317
+ // -------------------------------------------------------------------------
318
+ router.post('/copy-desk/proposals', api.requireBuilder, async (req, res) => {
319
+ try {
320
+ if (validateOrRespond(req, res, PROPOSAL_BODY_SCHEMA)) return;
321
+
322
+ const loaded = registryOrFail(res);
323
+ if (!loaded) return;
324
+
325
+ // Resolve the TARGET from the registry. Everything the patch says about
326
+ // what is being changed comes from here and never from the body — R02's
327
+ // snapshot rule (ADR 0178 §6), which matters more for a proposal than for
328
+ // a flag: a caller-supplied "current text" would let someone describe an
329
+ // edit to a sentence that does not exist, and the applier would then be
330
+ // editing a real file on the strength of a claim nobody checked.
331
+ const occurrences = registry.occurrencesOf(loaded.index, req.body.string_id);
332
+ if (!occurrences.length) {
333
+ return res.fail('unknown_string', 400, {
334
+ string_id: req.body.string_id,
335
+ what_this_means: 'The registry does not contain that string. It may have been reworded since this page loaded — reload the copy desk and look again.',
336
+ });
337
+ }
338
+
339
+ // One proposal changes ONE occurrence. When a string is shown in several
340
+ // places the caller must say which, because picking for them is exactly
341
+ // the silent wrong this goal exists to stop — and "the same sentence in
342
+ // seven places" is already its own signal on this page, with its own
343
+ // answer (change them all, deliberately, under a claim).
344
+ let entry = occurrences[0];
345
+ if (occurrences.length > 1) {
346
+ const file = req.body.file ? String(req.body.file) : null;
347
+ const line = Number.isInteger(req.body.line) ? req.body.line : null;
348
+ const picked = occurrences.filter((e) => e.file === file && e.line === line);
349
+ if (picked.length !== 1) {
350
+ return res.fail('ambiguous_string', 400, {
351
+ string_id: req.body.string_id,
352
+ occurrences: occurrences.map((e) => ({ file: e.file, line: e.line })),
353
+ what_this_means: 'This exact sentence is shown in more than one place, so a proposal has to name which one it means. That duplication is itself worth a look — the copy desk lists it under “Said more than once”.',
354
+ });
355
+ }
356
+ [entry] = picked;
357
+ }
358
+
359
+ const norm = proposals.normalizeProposalPayload({
360
+ body: req.body,
361
+ entry,
362
+ builderId: req.builder.id,
363
+ });
364
+ if (!norm.ok) return res.fail(norm.code, 400, norm.detail);
365
+ const { patch } = norm;
366
+
367
+ // An optional link back to the flag that prompted this. Validated so a
368
+ // typo'd id is a named 400 rather than a task quietly claiming a lineage
369
+ // it does not have. The flag is NOT auto-closed: closing is a person's
370
+ // verdict on someone else's judgement (ADR 0178 §4/§6), and a proposal is
371
+ // an argument for a change, not proof the change happened.
372
+ let flagRef = null;
373
+ if (Number.isInteger(req.body.flag_id)) {
374
+ const flag = await flags.getFlag(req.body.flag_id);
375
+ if (!flag) return res.fail('flag_not_found', 404, { flag_id: req.body.flag_id });
376
+ flagRef = flag.id;
377
+ }
378
+
379
+ const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
380
+ if (!lifecycle || !lifecycle.createTask) {
381
+ // An instance without the lifecycle module has no tasks to land into, so
382
+ // there is no honest way to accept a proposal. Refuse loudly rather than
383
+ // take the wording and drop it — a proposal that vanishes is worse than
384
+ // one that was never accepted.
385
+ return res.fail('lifecycle_unavailable', 503, {
386
+ what_this_means: 'This instance cannot hold tasks, and a copy proposal is a task. Nothing was recorded.',
387
+ });
388
+ }
389
+
390
+ // The version the work lands in: the one currently being built, matching
391
+ // how every other unattributed create-vector picks. `createTask` then
392
+ // defaults the goal to that version's catch-all, so a copy change is
393
+ // ordinary work on the board rather than a special case.
394
+ const versions = await lifecycle.listVersions();
395
+ const building = (versions || []).find((v) => v.status === 'building') || (versions || [])[0];
396
+ if (!building) return res.fail('no_open_version', 503);
397
+
398
+ const sourceRef = proposals.proposalSourceRef(patch);
399
+ const existing = lifecycle.findTaskBySourceRef
400
+ ? await lifecycle.findTaskBySourceRef({ source: 'copy-proposal', sourceRef })
401
+ : null;
402
+ if (existing) {
403
+ // A double-submit resolves to the task it already made. 200, not 201:
404
+ // nothing was created this time, and the caller gets the same task back.
405
+ return res.json({ ok: true, task: existing, created: false });
406
+ }
407
+
408
+ const body = proposals.proposalTaskBody(patch);
409
+ const task = await lifecycle.createTask({
410
+ versionId: building.id,
411
+ title: proposals.proposalTaskTitle(patch),
412
+ description: flagRef
413
+ ? `${body}\n\nRaised from copy-desk flag ${flagRef}.`
414
+ : body,
415
+ // BACKLOG, deliberately. A proposal is a request, and the board's triage
416
+ // step is where a person decides it is worth doing — the same gate a
417
+ // captured idea passes. Landing it `ready` would let anyone with an
418
+ // opinion mint claimable work.
419
+ status: 'backlog',
420
+ kind: 'feature',
421
+ priority: 4,
422
+ touches: [patch.file],
423
+ source: 'copy-proposal',
424
+ sourceRef,
425
+ createdBy: req.builder.id,
426
+ });
427
+
428
+ res.status(201).json({ ok: true, task, created: true, patch });
429
+ } catch (err) {
430
+ log.error({ err }, 'POST /copy-desk/proposals failed');
431
+ if (!res.headersSent) res.fail('copy_proposal_failed', 500);
432
+ }
433
+ });
434
+
271
435
  return router;
272
436
  };