@bongos/core 1.21.106 → 1.21.108

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 (50) hide show
  1. package/.bongos-core.json +72 -52
  2. package/.claude/skills/goal-create/SKILL.md +8 -3
  3. package/.claude/skills/idea-triage/SKILL.md +1 -1
  4. package/.claude/skills/planning-session/SKILL.md +6 -4
  5. package/clients/bongos-client/README.md +1 -1
  6. package/clients/bongos-client/bongos-client.global.js +4 -0
  7. package/clients/bongos-client/index.cjs +4 -0
  8. package/clients/bongos-client/index.d.ts +6 -1
  9. package/clients/bongos-client/index.mjs +4 -0
  10. package/docs/api/openapi.json +91 -2
  11. package/docs/api-reference.md +5 -3
  12. package/docs/copy-inventory.md +25 -19
  13. package/docs/copy-registry.json +92 -37
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-readings.json +189 -188
  16. package/migrations/core_275_claims_auto_pickup.sql +24 -0
  17. package/modules/copy-desk/docx.js +27 -7
  18. package/modules/copy-desk/page-status.js +3 -1
  19. package/modules/copy-desk/pages.js +44 -5
  20. package/modules/copy-desk/routes/copy-desk.js +4 -3
  21. package/modules/copy-desk/tests/copy_docx.mjs +38 -0
  22. package/modules/copy-desk/tests/copy_page_draft.mjs +79 -0
  23. package/modules/government/decision-rules.js +1 -0
  24. package/modules/hall-ui/public/paintbrush.js +152 -12
  25. package/modules/hall-ui/public/studio.js +3 -1
  26. package/modules/hall-ui/public/style.css +16 -0
  27. package/modules/hall-ui/public/tweak-editor-lib.js +24 -12
  28. package/modules/hall-ui/records/craft-mode.md +5 -0
  29. package/modules/hall-ui/records/tweak-editor.md +1 -1
  30. package/modules/lifecycle/autopickup.js +94 -0
  31. package/modules/lifecycle/db-claims.js +18 -4
  32. package/modules/lifecycle/routes/claims.js +12 -1
  33. package/modules/lifecycle/routes/project-settings.js +59 -0
  34. package/package-lock.json +2 -2
  35. package/package.json +1 -1
  36. package/release-notes.json +16 -0
  37. package/scripts/gds/government-walk.js +1 -0
  38. package/scripts/gds/run-unit-tests.js +3 -0
  39. package/scripts/gds/seed-bv2-ideator-surface-tasks.js +12 -21
  40. package/scripts/gds/seed-verify-criteria.js +73 -18
  41. package/src/bongos/project-settings.js +79 -0
  42. package/src/module-api.js +8 -1
  43. package/tests/autopickup_cap.mjs +236 -0
  44. package/tests/autopickup_cap_db.mjs +115 -0
  45. package/tests/copy_desk_page_submit.mjs +36 -0
  46. package/tests/government_act_alone_buildings.mjs +1 -0
  47. package/tests/hall_tweak_editor.mjs +19 -3
  48. package/tests/paintbrush.mjs +151 -2
  49. package/tests/planning_session_skill.mjs +40 -0
  50. package/tests/seed_verify_criteria.mjs +130 -0
@@ -0,0 +1,24 @@
1
+ -- core_275 a claim can carry the auto_pickup mark
2
+ -- Applied under task 1004677 (BV2.TW19a, ADR 0371 D5/D6).
3
+ --
4
+ -- WHY. The Autobongos tweak lane picks up a submitted page round with nobody at a
5
+ -- terminal, and each pickup costs the project a grade nobody chose to spend. So the
6
+ -- lane claims with a mark, and the SERVER counts marked claims at claim time:
7
+ -- tweak.autopickup_monthly_cap per calendar month, and at most two per round. The
8
+ -- count is read from these rows (the ledger), never from a stored counter.
9
+ --
10
+ -- SHAPE. One nullable boolean. NULL and false both mean "an ordinary claim"; only
11
+ -- true is counted. Nullable with no default, so the column is additive and an
12
+ -- older release that never names it keeps inserting exactly as before.
13
+ --
14
+ -- The partial index serves the two counts (this month's marked claims, and one
15
+ -- round's marked claims), and stays tiny: only marked rows are in it.
16
+
17
+ ALTER TABLE claims ADD COLUMN IF NOT EXISTS auto_pickup boolean;
18
+
19
+ CREATE INDEX IF NOT EXISTS idx_claims_auto_pickup
20
+ ON claims (claimed_at, task_id)
21
+ WHERE auto_pickup IS TRUE;
22
+
23
+ INSERT INTO schema_migrations (version) VALUES ('core_275_claims_auto_pickup')
24
+ ON CONFLICT (version) DO NOTHING;
@@ -82,6 +82,8 @@ const COMMENT_TEXT = Object.freeze({
82
82
  shared: 'changes this on every page',
83
83
  unplaced: 'goes to an engineer',
84
84
  });
85
+ // A line the draft removes (task 1004696): a comment, but no style of its own.
86
+ const REMOVED_COMMENT = 'marked to be removed: an engineer takes it out. Rewrite it here to keep it';
85
87
  const NOTE_TEXT = 'Rewrite any line where it stands, then upload this file back into the editor. '
86
88
  + 'Keep one paragraph per line: a paragraph added, removed or moved is refused. '
87
89
  + 'Keep each {…} as it is: the page fills in a value there.';
@@ -368,16 +370,25 @@ function textOf(el) {
368
370
  // The lines as the file shows them: the draft's `after` where the draft is on
369
371
  // this very reading, else the page's current text. A draft saved on an older
370
372
  // reading is not shown: its keys may name other lines now.
373
+ //
374
+ // A line the draft removes (task 1004696) shows the page's words with a margin
375
+ // comment saying so: an empty paragraph is not a line, so blanking it would
376
+ // break the file's one-paragraph-per-line shape. Rewriting it in Word keeps it.
371
377
  function shownLines(readingPage, draft) {
372
378
  const after = new Map();
379
+ const removed = new Set();
373
380
  if (draft && draft.reading_hash === readingPage.reading_hash) {
374
- for (const l of draft.lines || []) after.set(l.key, l.after);
381
+ for (const l of draft.lines || []) {
382
+ if (l.remove) removed.add(l.key);
383
+ else after.set(l.key, l.after);
384
+ }
375
385
  }
376
386
  return (readingPage.lines || []).map((l) => ({
377
387
  key: l.key,
378
388
  section: l.section == null ? '' : String(l.section),
379
389
  placement: l.placement,
380
390
  text: after.has(l.key) ? after.get(l.key) : l.text,
391
+ ...(removed.has(l.key) ? { removed: true } : {}),
381
392
  }));
382
393
  }
383
394
 
@@ -413,8 +424,8 @@ function documentXml({ title, lines }) {
413
424
  current = l.section;
414
425
  body.push(styledPara(STYLE.heading, run(l.section)));
415
426
  }
416
- const note = COMMENT_TEXT[l.placement];
417
- const style = note ? STYLE[l.placement] : null;
427
+ const style = COMMENT_TEXT[l.placement] ? STYLE[l.placement] : null;
428
+ const note = l.removed ? REMOVED_COMMENT : COMMENT_TEXT[l.placement];
418
429
  const sdt = `<w:sdt><w:sdtPr><w:tag w:val="${escapeXml(l.key)}"/><w:id w:val="${idx + 1}"/><w:text/></w:sdtPr>`
419
430
  + `<w:sdtContent>${run(l.text)}</w:sdtContent></w:sdt>`;
420
431
  let inner = sdt;
@@ -853,10 +864,18 @@ function mapUpload({ readingPage, upload }) {
853
864
  // file is the whole intended state. The lines go to pages.resolveDraft, the
854
865
  // autosave's own resolver, which drops unchanged lines and takes every target
855
866
  // from the reading.
867
+ //
868
+ // A line the draft removes (task 1004696) was shown with the page's words, so
869
+ // those are what the file is compared with: left alone, it stays a removal
870
+ // ({ key, remove: true }); rewritten in Word, the rewrite wins.
856
871
  function mergeUpload({ readingPage, texts, baseline, draft }) {
857
872
  const draftAfter = new Map();
873
+ const removed = new Set();
858
874
  if (draft && draft.reading_hash === readingPage.reading_hash) {
859
- for (const l of draft.lines || []) draftAfter.set(l.key, l.after);
875
+ for (const l of draft.lines || []) {
876
+ if (l.remove) removed.add(l.key);
877
+ else draftAfter.set(l.key, l.after);
878
+ }
860
879
  }
861
880
  const same = (a, b) => foldQuotes(tidy(a)) === foldQuotes(tidy(b));
862
881
  const lines = [];
@@ -867,9 +886,10 @@ function mergeUpload({ readingPage, texts, baseline, draft }) {
867
886
  const uploaded = texts.has(l.key) ? tidy(pageQuotes(l.text, texts.get(l.key))) : current;
868
887
  const changedInFile = baseline && baseline.has(l.key) ? !same(uploaded, baseline.get(l.key)) : !same(uploaded, current);
869
888
  const after = changedInFile ? uploaded : current;
870
- if (changedInFile && !same(after, current)) fromFile.push(l.key);
871
- else if (draftAfter.has(l.key)) kept.push(l.key);
872
- lines.push({ key: l.key, after });
889
+ const tookFile = changedInFile && !same(after, current);
890
+ if (tookFile) fromFile.push(l.key);
891
+ else if (draftAfter.has(l.key) || removed.has(l.key)) kept.push(l.key);
892
+ lines.push(removed.has(l.key) && !tookFile ? { key: l.key, remove: true } : { key: l.key, after });
873
893
  }
874
894
  return { lines, from_file: fromFile, kept_from_draft: kept };
875
895
  }
@@ -587,7 +587,9 @@ function composeEditor({ page, readingPage, round, builderId }) {
587
587
  draft = {
588
588
  reading_hash: d.draft.reading_hash,
589
589
  saved_at: d.draft.saved_at || null,
590
- lines: d.draft.lines.filter((l) => keys.has(l.key)).map((l) => ({ key: l.key, after: l.after })),
590
+ // A removal (task 1004696) reads as an empty `after` plus `remove`,
591
+ // which is how every editor holds it.
592
+ lines: d.draft.lines.filter((l) => keys.has(l.key)).map((l) => (l.remove ? { key: l.key, after: '', remove: true } : { key: l.key, after: l.after })),
591
593
  };
592
594
  } else {
593
595
  olderReading = true;
@@ -115,7 +115,9 @@ function composeDraftBlock(d) {
115
115
  page_id: d.page_id,
116
116
  reading_hash: d.reading_hash,
117
117
  saved_at: d.saved_at,
118
- lines: (d.lines || []).map((l) => ({ key: l.key, section: l.section, before: l.before, after: l.after, placement: l.placement })),
118
+ // `remove` is written only on a removal (task 1004696), so every draft
119
+ // that removes nothing keeps its bytes.
120
+ lines: (d.lines || []).map((l) => ({ key: l.key, section: l.section, before: l.before, after: l.after, placement: l.placement, ...(l.remove ? { remove: true } : {}) })),
119
121
  });
120
122
  }
121
123
 
@@ -266,6 +268,12 @@ function replaceBlock(markdown, fence, block) {
266
268
  // text is not a change and is dropped, so only changed lines are stored (D4).
267
269
  // Placeholder arithmetic is NOT checked here: an autosave lands mid-sentence,
268
270
  // and the check belongs to the submit (and again to the applier).
271
+ //
272
+ // A line sent as { key, remove: true } is the artist recommending the page stop
273
+ // showing it (task 1004696). It is stored with `after` empty and `remove: true`;
274
+ // an empty `after` WITHOUT the flag is still line_empty, so no client removes a
275
+ // line by accident. Removing words is a code change, so the submit hands every
276
+ // removal to an engineer (freezeBatch).
269
277
  function resolveDraft({ readingPage, body, savedAt }) {
270
278
  if (!readingPage || !Array.isArray(readingPage.lines)) return fail('reading_missing');
271
279
  const b = body || {};
@@ -282,11 +290,19 @@ function resolveDraft({ readingPage, body, savedAt }) {
282
290
  const out = [];
283
291
  for (const raw of list) {
284
292
  const key = raw && typeof raw.key === 'string' ? raw.key : '';
285
- if (!LINE_KEY_RE.test(key) || typeof raw.after !== 'string') return fail('line_malformed', { line: raw == null ? null : raw });
293
+ const remove = !!raw && raw.remove === true;
294
+ const malformed = !LINE_KEY_RE.test(key)
295
+ || (raw.remove !== undefined && typeof raw.remove !== 'boolean')
296
+ || (remove ? (raw.after !== undefined && tidy(raw.after) !== '') : typeof raw.after !== 'string');
297
+ if (malformed) return fail('line_malformed', { line: raw == null ? null : raw });
286
298
  if (seen.has(key)) return fail('duplicate_line', { key });
287
299
  seen.add(key);
288
300
  const at = byKey.get(key);
289
301
  if (!at) { unknown.push(key); continue; }
302
+ if (remove) {
303
+ out.push({ key, section: at.section == null ? null : at.section, before: at.text, after: '', placement: at.placement, remove: true });
304
+ continue;
305
+ }
290
306
  const after = tidy(raw.after);
291
307
  if (after === tidy(at.text)) continue;
292
308
  if (!after) { empty.push(key); continue; }
@@ -307,7 +323,7 @@ function resolveDraft({ readingPage, body, savedAt }) {
307
323
  function sameDraft(saved, next) {
308
324
  if (!saved || !next) return false;
309
325
  const pick = (d) => JSON.stringify([d.page_id, d.reading_hash,
310
- (d.lines || []).map((l) => [l.key, l.section == null ? null : l.section, l.before, l.after, l.placement])]);
326
+ (d.lines || []).map((l) => [l.key, l.section == null ? null : l.section, l.before, l.after, l.placement, !!l.remove])]);
311
327
  return pick(saved) === pick(next);
312
328
  }
313
329
 
@@ -356,7 +372,8 @@ function goesToEngineer(line) {
356
372
  // frozen: the artist fixes those lines and submits again. `batch` is the
357
373
  // `page-tweak` block's content without its `unplaced_tasks` (the lifecycle
358
374
  // transaction fills those in once the engineer tasks exist). `unplaced` is the
359
- // lines taken out of it.
375
+ // lines taken out of it: the rewrites the applier cannot place, and every
376
+ // removal (`remove: true`).
360
377
  function freezeBatch({ readingPage, draft, submittedAt }) {
361
378
  if (!readingPage || !Array.isArray(readingPage.lines)) return fail('reading_missing');
362
379
  if (!draft || !Array.isArray(draft.lines) || !draft.lines.length) return fail('nothing_to_submit');
@@ -371,6 +388,9 @@ function freezeBatch({ readingPage, draft, submittedAt }) {
371
388
  if (keys.has(at.key)) { refused.push({ key: d.key, section: d.section == null ? null : d.section, before: d.before, code: 'ambiguous_target' }); continue; }
372
389
  keys.add(at.key);
373
390
  const line = { key: at.key, section: at.section == null ? null : at.section, before: at.text, after: d.after, placement: at.placement };
391
+ // A removal is a code change wherever the line lives, so it never joins
392
+ // the applier's batch (task 1004696).
393
+ if (d.remove) { unplaced.push({ ...line, after: '', remove: true }); continue; }
374
394
  if (goesToEngineer(at)) { unplaced.push(line); continue; }
375
395
  const want = placeholderCount(at.text);
376
396
  const got = placeholderCount(d.after);
@@ -388,7 +408,8 @@ function freezeBatch({ readingPage, draft, submittedAt }) {
388
408
  }
389
409
 
390
410
  // The engineer task an unplaced rewrite files (ADR 0341 D4): one per line,
391
- // keyed so a repeated submit finds the task it already filed.
411
+ // keyed so a repeated submit finds the task it already filed. A removal
412
+ // (task 1004696) files the same way, worded as a removal.
392
413
  function unplacedSourceRef(roundTaskId, key) {
393
414
  return `${UNPLACED_SOURCE}/${roundTaskId}/${key}`;
394
415
  }
@@ -399,10 +420,28 @@ function clip(text, n) {
399
420
  }
400
421
 
401
422
  function unplacedTaskTitle(page, line) {
423
+ if (line.remove) return `Remove "${clip(line.before, 60)}" (${page.id})`;
402
424
  return `Place and reword "${clip(line.before, 60)}" (${page.id})`;
403
425
  }
404
426
 
427
+ function removalTaskBody(page, line, roundTaskId) {
428
+ const shared = line.placement === 'shared'
429
+ ? ['It is shared shell text: it shows on every page of the surface, so removing it removes it everywhere.', '']
430
+ : [];
431
+ return [
432
+ `An artist recommends that **${String(page.title || page.id)}** (\`${page.id}\`${page.path ? `, ${page.path}` : ''}) stop showing a line, from the page's tweak round. Removing words from a page is a code change, so it comes to an engineer (task 1004696).`,
433
+ '',
434
+ ...shared,
435
+ `- Section: ${line.section == null ? '(none)' : line.section}`,
436
+ `- Line: ${line.key} in the page reading`,
437
+ `- It says now: ${JSON.stringify(line.before)}`,
438
+ '',
439
+ `Find where this text is rendered and take it out, along with any element or spacing it would leave empty. If removing it would lose something a reader needs, say so on this task instead. The page's tweak round (task ${roundTaskId}) does not wait for this.`,
440
+ ].join('\n');
441
+ }
442
+
405
443
  function unplacedTaskBody(page, line, roundTaskId) {
444
+ if (line.remove) return removalTaskBody(page, line, roundTaskId);
406
445
  const where = line.placement === 'shared'
407
446
  ? 'It is shared shell text (it shows on every page of the surface), and the copy registry has no row for it.'
408
447
  : 'The copy registry cannot place it: the string is built somewhere the inventory does not read (data from an API, a string assembled in code, or markup the extractors miss).';
@@ -114,7 +114,8 @@ const ASK_BODY_SCHEMA = Object.freeze({
114
114
  reason: { required: true, type: 'string', maxLength: flags.MAX_REASON_CHARS, minLength: 1 },
115
115
  });
116
116
  // The draft autosave (task 1004319 / TW08): the reading the editor was built
117
- // on, and every changed line as { key, after }. Nothing else, and in
117
+ // on, and every changed line as { key, after }, or { key, remove: true } for a
118
+ // line the artist recommends removing (task 1004696). Nothing else, and in
118
119
  // particular no `before`, `section`, `placement` or file: every fact about a
119
120
  // target is resolved from the page reading (pages.resolveDraft).
120
121
  const DRAFT_BODY_SCHEMA = Object.freeze({
@@ -155,10 +156,10 @@ const PAGE_REFUSAL_STATUS = Object.freeze({
155
156
  const PAGE_REFUSAL_MEANS = Object.freeze({
156
157
  reading_moved: 'The page changed since the editor loaded it, so a line key may now mean a different line. Reload the page to get the current reading; nothing was saved.',
157
158
  unknown_line: 'The page reading has no line with that key. Reload the editor.',
158
- line_empty: 'A line cannot be rewritten to nothing. Removing text from a page is a code change; leave the line as it is or give it new words.',
159
+ line_empty: 'A line cannot be rewritten to nothing. To recommend the page stop showing it, send the line as { key, remove: true }: removing text is a code change, so the submit hands it to an engineer.',
159
160
  line_too_long: `A line holds at most ${pages.MAX_LINE_CHARS} characters.`,
160
161
  lines_refused: 'Some lines cannot be frozen as written: the page no longer shows them (target_gone), shows the same words in more than one place (ambiguous_target), or the rewrite dropped or added a {…} hole (placeholder_mismatch). Fix or remove each named line and submit again; nothing was submitted.',
161
- nothing_to_submit: 'The draft has no rewritten lines yet.',
162
+ nothing_to_submit: 'The draft has no rewritten or removed lines yet.',
162
163
  structure_changed: 'The file\'s lines no longer match the page: a paragraph was added, removed or moved, or a heading changed. Each one is listed. Put them back (one paragraph per line, in the page\'s order) or download the page again, and upload once more; nothing was saved.',
163
164
  not_a_docx: 'Upload the .docx file this page downloaded, as the request body. Nothing was saved.',
164
165
  upload_too_large: `A page file is at most ${Math.round(docx.MAX_UPLOAD_BYTES / (1024 * 1024))} MB. Nothing was saved.`,
@@ -246,6 +246,44 @@ test('a line changed in Word wins over the draft; without the file\'s baseline t
246
246
  assert.deepEqual(merged.from_file, ['L0002']);
247
247
  });
248
248
 
249
+ // ---------------------------------------------------------------------------
250
+ // A line the draft removes (task 1004696).
251
+ // ---------------------------------------------------------------------------
252
+
253
+ const removal = draftOf([
254
+ { key: 'L0002', section: 'greeting', before: 'Welcome back', after: 'Hello again', placement: 'placed' },
255
+ { key: 'L0006', section: 'footer', before: 'Resume', after: '', placement: 'placed', remove: true },
256
+ ]);
257
+
258
+ test('the download shows a removed line with the page\'s words and a margin comment, never as an empty paragraph', () => {
259
+ const files = unzipAll(download(removal));
260
+ assert.match(files.get('word/document.xml'), />Resume</);
261
+ assert.match(files.get('word/comments.xml'), /marked to be removed: an engineer takes it out\. Rewrite it here to keep it/);
262
+ assert.equal(upload(download(removal)).baseline.get('L0006'), 'Resume', 'the file records the words it showed');
263
+ assert.doesNotMatch(unzipAll(download()).get('word/comments.xml'), /marked to be removed/, 'no removal, no such comment');
264
+ });
265
+
266
+ test('a removed line left alone in Word stays a removal through the upload', () => {
267
+ const edited = resave(download(removal), { document: (xml) => typeInto(xml, 'L0004', wordRuns(['Nothing waits'])) });
268
+ const { merged } = roundTrip(edited, removal);
269
+ assert.deepEqual(merged.lines.find((l) => l.key === 'L0006'), { key: 'L0006', remove: true });
270
+ assert.deepEqual(merged.kept_from_draft, ['L0002', 'L0006']);
271
+ const resolved = pages.resolveDraft({ readingPage: READING, body: { reading_hash: 'h-now', lines: merged.lines }, savedAt: 't' });
272
+ assert.equal(resolved.ok, true, JSON.stringify(resolved));
273
+ assert.deepEqual(resolved.draft.lines.map((l) => [l.key, l.after, !!l.remove]),
274
+ [['L0002', 'Hello again', false], ['L0004', 'Nothing waits', false], ['L0006', '', true]]);
275
+ // Without the file's record of what it showed, an unchanged line is still the page's words: the removal stays.
276
+ const noBaseline = roundTrip(resave(download(removal), { drop: ['customXml/item1.xml'] }), removal).merged;
277
+ assert.deepEqual(noBaseline.lines.find((l) => l.key === 'L0006'), { key: 'L0006', remove: true });
278
+ });
279
+
280
+ test('a removed line rewritten in Word becomes that rewrite', () => {
281
+ const edited = resave(download(removal), { document: (xml) => typeInto(xml, 'L0006', wordRuns(['Pick up where you were'])) });
282
+ const { merged } = roundTrip(edited, removal);
283
+ assert.deepEqual(merged.lines.find((l) => l.key === 'L0006'), { key: 'L0006', after: 'Pick up where you were' });
284
+ assert.deepEqual(merged.from_file, ['L0006']);
285
+ });
286
+
249
287
  // ---------------------------------------------------------------------------
250
288
  // Structure changes are refused, by name.
251
289
  // ---------------------------------------------------------------------------
@@ -198,5 +198,84 @@ test('the engineer task carries the page, the line and the wording, keyed per ro
198
198
  }
199
199
  });
200
200
 
201
+ // ---------------------------------------------------------------------------
202
+ // Removing a line (task 1004696): the artist recommends the page stop showing
203
+ // it. Saved as { key, remove: true }; at submit it goes to an engineer, never
204
+ // into the applier's batch.
205
+ // ---------------------------------------------------------------------------
206
+
207
+ test('a removal is saved as remove: true with an empty after; an empty after WITHOUT the flag is still line_empty', () => {
208
+ const r = pages.resolveDraft({ readingPage: READING, savedAt: SAVED_AT, body: body([
209
+ { key: 'L0006', remove: true },
210
+ { key: 'L0003', after: 'Build it here', remove: false },
211
+ ]) });
212
+ assert.equal(r.ok, true, JSON.stringify(r));
213
+ assert.deepEqual(r.draft.lines, [
214
+ { key: 'L0003', section: 'hero', before: 'Build here', after: 'Build it here', placement: 'placed' },
215
+ { key: 'L0006', section: 'footer', before: 'Resume', after: '', placement: 'placed', remove: true },
216
+ ]);
217
+ const code = (lines) => pages.resolveDraft({ readingPage: READING, savedAt: SAVED_AT, body: body(lines) }).code;
218
+ assert.equal(code([{ key: 'L0006', after: '' }]), 'line_empty', 'no client removes a line by accident');
219
+ assert.equal(code([{ key: 'L0006', remove: true, after: 'Keep me' }]), 'line_malformed', 'a removal carries no words');
220
+ assert.equal(code([{ key: 'L0006', remove: 'yes' }]), 'line_malformed');
221
+ assert.equal(code([{ key: 'L0099', remove: true }]), 'unknown_line');
222
+ assert.equal(pages.resolveDraft({ readingPage: READING, savedAt: SAVED_AT, body: body([{ key: 'L0006', remove: true, after: ' ' }]) }).ok, true, 'an empty after beside the flag is the same removal');
223
+ });
224
+
225
+ test('the stored block keeps a removal, and a draft with none keeps its bytes', () => {
226
+ const plain = draftOf([{ key: 'L0003', after: 'Build it here' }]);
227
+ assert.doesNotMatch(pages.composeDraftBlock(plain), /remove/, 'no removal, no new field');
228
+ const withRemoval = draftOf([{ key: 'L0003', after: 'Build it here' }, { key: 'L0006', remove: true }]);
229
+ const parsed = pages.parseDraftBlock(pages.composeDraftBlock(withRemoval));
230
+ assert.equal(parsed.ok, true);
231
+ assert.deepEqual(parsed.draft.lines[1], { key: 'L0006', section: 'footer', before: 'Resume', after: '', placement: 'placed', remove: true });
232
+ assert.equal(pages.sameDraft(parsed.draft, withRemoval), true);
233
+ assert.equal(pages.sameDraft(withRemoval, draftOf([{ key: 'L0003', after: 'Build it here' }, { key: 'L0006', after: 'Resume now' }])), false);
234
+ });
235
+
236
+ test('at submit a removal goes to an engineer, wherever the line lives, and never into the batch', () => {
237
+ const draft = draftOf([
238
+ { key: 'L0001', remove: true }, // shared, with a registry row: the applier could place it, but removing is code
239
+ { key: 'L0003', after: 'Build it here' },
240
+ { key: 'L0004', remove: true }, // a line with holes: no placeholder arithmetic for a removal
241
+ ]);
242
+ const f = pages.freezeBatch({ readingPage: READING, draft, submittedAt: 'S' });
243
+ assert.equal(f.ok, true, JSON.stringify(f));
244
+ assert.deepEqual(f.batch.lines.map((l) => l.key), ['L0003']);
245
+ assert.deepEqual(f.unplaced.map((l) => [l.key, l.before, l.after, l.remove]), [
246
+ ['L0001', 'Skip to content', '', true],
247
+ ['L0004', '{…} of {…} shipped', '', true],
248
+ ]);
249
+ });
250
+
251
+ test('a draft of only removals still submits: its batch is empty and every line goes to an engineer', () => {
252
+ const f = pages.freezeBatch({ readingPage: READING, draft: draftOf([{ key: 'L0006', remove: true }]), submittedAt: 'S' });
253
+ assert.equal(f.ok, true, JSON.stringify(f));
254
+ assert.deepEqual(f.batch.lines, []);
255
+ assert.deepEqual(f.unplaced.map((l) => l.key), ['L0006']);
256
+ });
257
+
258
+ test('a removal the page no longer shows is refused target_gone, like any line', () => {
259
+ const draft = draftOf([{ key: 'L0006', remove: true }]);
260
+ const moved = { ...READING, reading_hash: 'h-next', lines: READING.lines.filter((l) => l.key !== 'L0006') };
261
+ const f = pages.freezeBatch({ readingPage: moved, draft, submittedAt: 'S' });
262
+ assert.equal(f.code, 'lines_refused');
263
+ assert.deepEqual(f.detail.refused.map((x) => [x.key, x.code]), [['L0006', 'target_gone']]);
264
+ });
265
+
266
+ test('the removal task is worded as a removal: what to take out, where, and that the round does not wait', () => {
267
+ const page = { id: 'landing:index', title: 'Home', path: '/' };
268
+ const line = { key: 'L0006', section: 'footer', before: 'Resume', after: '', placement: 'placed', remove: true };
269
+ assert.equal(pages.unplacedTaskTitle(page, line), 'Remove "Resume" (landing:index)');
270
+ const b = pages.unplacedTaskBody(page, line, '5001');
271
+ for (const want of ['landing:index', 'Home', 'L0006', 'footer', '"Resume"', 'take it out', 'task 5001', 'does not wait']) {
272
+ assert.ok(b.includes(want), `the body names ${want}`);
273
+ }
274
+ assert.doesNotMatch(b, /artist's wording/, 'there is no new wording to apply');
275
+ const shared = pages.unplacedTaskBody(page, { ...line, placement: 'shared' }, '5001');
276
+ assert.match(shared, /removes it everywhere/);
277
+ assert.equal(pages.unplacedSourceRef('5001', 'L0006'), 'page-tweak-unplaced/5001/L0006', 'keyed like any engineer line, so a repeated submit finds it');
278
+ });
279
+
201
280
  console.log(`\ncopy_page_draft: ${passed} passed, ${failed} failed`);
202
281
  if (failed) process.exit(1);
@@ -66,6 +66,7 @@ const AREAS = Object.freeze([
66
66
  today: 'whoever may change the project settings', acts: Object.freeze([
67
67
  'PATCH /project-settings/rot-days',
68
68
  'PATCH /project-settings/ship-mode',
69
+ 'PATCH /project-settings/tweak-autopickup',
69
70
  'PATCH /work-categories/:id',
70
71
  ]) }),
71
72
  Object.freeze({ key: 'disputes', name: 'Disputes', gate: 'security.report.adjudicate',