@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
@@ -13,6 +13,14 @@
13
13
  // Owner, 2026-10-04: the edits are REAL wording changes (the page-tweak path),
14
14
  // kept apart from the palette's feedback docs.
15
15
  //
16
+ // Owner, 2026-10-09 (task 1004696): an artist can also recommend REMOVING a
17
+ // line ("Remove this line" on the line they are in, or by emptying it). A
18
+ // removal is saved as { key, remove: true } and, being a code change, goes to
19
+ // an engineer when the page is sent. And the artist review (the palette's
20
+ // sheet, page-notes.js) is one press away while editing and again once the
21
+ // words are sent, so the words and the look are reviewed in one sitting. This
22
+ // file only opens that sheet; it still never writes a feedback doc itself.
23
+ //
16
24
  // Taking the page is the copy desk's claim: artists only, one at a time, and
17
25
  // the page shows as being tweaked while it is held. The walls are the server's
18
26
  // (modules/copy-desk); this file only draws. A word the page fills in for
@@ -23,6 +31,8 @@
23
31
  var API = '/api/bongos/copy-desk/pages/';
24
32
  var SAVE_MS = 700;
25
33
  var HOLE = '{…}';
34
+ // The artist review's own words for itself (page-notes.js), on the button that opens it.
35
+ var NOTES_TITLE = 'Write what this page should look like; each point becomes an idea';
26
36
  // Never made editable: form controls, code, drawings, and the hall's own chrome.
27
37
  var SKIP = 'script, style, svg, textarea, input, select, option, button, code, pre, [contenteditable="false"], #paintbrush';
28
38
  var SCRIPT_V = (function () {
@@ -40,6 +50,7 @@
40
50
  }
41
51
  function tidy(s) { return String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); }
42
52
  function plural(n, one, many) { return n + ' ' + (n === 1 ? one : many); }
53
+ function clip(s, n) { s = tidy(s); return s.length > n ? s.slice(0, n - 1) + '…' : s; }
43
54
  // The page's id in the readings (docs/page-readings.json): builders:<data-page>.
44
55
  function pageId() {
45
56
  var b = document.body || { dataset: {} };
@@ -109,16 +120,32 @@
109
120
  '<button class="pb__btn pb__btn--go" type="button" data-pb-take data-pb-focus>Take this page</button>' +
110
121
  '</div>';
111
122
  }
123
+ // The line the artist is in, and what can be done with it: remove it, or keep
124
+ // a removed one after all. Nothing until a line has been clicked or focused.
125
+ function currentHtml() {
126
+ var line = st.current ? lineOf(st.current) : null;
127
+ if (!line) return '';
128
+ var gone = removed(line);
129
+ return '<div class="pb__current">' +
130
+ '<span class="pb__current-words">' + (gone ? '<span class="pb__tag">Remove</span> ' : '') + esc(clip(line.text, 70)) + '</span>' +
131
+ (gone
132
+ ? '<button class="pb__btn pb__btn--quiet" type="button" data-pb-keep>Keep this line</button>'
133
+ : '<button class="pb__btn pb__btn--quiet" type="button" data-pb-remove>Remove this line</button>') +
134
+ '</div>';
135
+ }
112
136
  function editingHtml() {
113
137
  var changed = changes().length;
114
138
  var save = st.save === 'saving' ? 'saving…' : st.save === 'failed' ? 'not saved' : 'saved';
115
139
  var rest = st.unreached;
116
140
  return '<p class="pb__line" aria-live="polite"><strong>' + esc(plural(changed, 'change', 'changes')) + '</strong> · ' + esc(save) +
117
141
  (st.error ? ' · <span class="pb__error-inline">' + esc(st.error) + '</span>' : '') + '</p>' +
118
- '<p class="pb__hint">Click any underlined words and type over them.' +
142
+ '<p class="pb__hint">Click any underlined words and type over them, or remove a line the page does not need.' +
119
143
  (rest ? ' ' + esc(plural(rest, 'other line', 'other lines')) + ' of this page ' + (rest === 1 ? 'is' : 'are') + ' in the <a href="' + esc(editorHref(st.id)) + '">tweak editor</a>.' : '') + '</p>' +
144
+ currentHtml() +
145
+ '<p class="pb__error" role="alert">' + esc(st.notesError || '') + '</p>' +
120
146
  '<div class="pb__actions">' +
121
147
  '<button class="pb__btn" type="button" data-pb-stop>Stop editing</button>' +
148
+ '<button class="pb__btn" type="button" data-pb-notes title="' + esc(NOTES_TITLE) + '">Artist review</button>' +
122
149
  '<button class="pb__btn pb__btn--go" type="button" data-pb-review' + (changed ? '' : ' disabled') + '>Review ' + esc(plural(changed, 'change', 'changes')) + '</button>' +
123
150
  '</div>';
124
151
  }
@@ -126,13 +153,20 @@
126
153
  var rows = changes();
127
154
  return '<p class="pb__line"><strong>Your changes to this page</strong></p>' +
128
155
  '<ol class="pb__changes">' + rows.map(function (c) {
156
+ if (c.remove) {
157
+ return '<li class="pb__change pb__change--remove">' +
158
+ '<span class="pb__where">' + esc(c.section || 'this page') + '</span>' +
159
+ '<span class="pb__was"><span class="pb__tag">Remove</span> ' + esc(c.was) + '</span>' +
160
+ '</li>';
161
+ }
129
162
  return '<li class="pb__change">' +
130
163
  '<span class="pb__where">' + esc(c.section || 'this page') + '</span>' +
131
164
  '<span class="pb__was"><span class="pb__tag">Was</span> ' + esc(c.was) + '</span>' +
132
165
  '<span class="pb__now"><span class="pb__tag">Now</span> ' + esc(c.now) + '</span>' +
133
166
  '</li>';
134
167
  }).join('') + '</ol>' +
135
- '<p class="pb__hint">Sending submits the page: an engineer applies the new words, and they come back to you to approve before anyone sees them.</p>' +
168
+ '<p class="pb__hint">Sending submits the page: an engineer applies the new words, and they come back to you to approve before anyone sees them.' +
169
+ (rows.some(function (c) { return c.remove; }) ? ' A line you remove goes to an engineer to take out.' : '') + '</p>' +
136
170
  '<p class="pb__error" role="alert">' + esc(err || '') + '</p>' +
137
171
  '<div class="pb__actions">' +
138
172
  '<button class="pb__btn" type="button" data-pb-back>Keep editing</button>' +
@@ -140,13 +174,29 @@
140
174
  '</div>';
141
175
  }
142
176
 
177
+ // After sending: the words are on their way, and the look is one press away.
178
+ function sentHtml(n) {
179
+ return '<p class="pb__line" role="status">Sent for approval: ' + esc(plural(n, 'change', 'changes')) + '. An engineer applies the new words, and they come back to you in the studio to approve.</p>' +
180
+ '<p class="pb__line"><strong>Review how this page looks too?</strong> Write what you would change about it, and each point becomes an idea under your name.</p>' +
181
+ '<p class="pb__error" role="alert">' + esc(st.notesError || '') + '</p>' +
182
+ '<div class="pb__actions">' +
183
+ '<button class="pb__btn" type="button" data-pb-close>Not now</button>' +
184
+ '<button class="pb__btn pb__btn--go" type="button" data-pb-notes data-pb-focus>Start an artist review</button>' +
185
+ '</div>';
186
+ }
187
+
143
188
  // ---------- the words ----------
144
189
  // Every line the artist has changed, in the page's reading order.
190
+ // A removal is a line whose words were emptied (the lib's rule, task 1004696).
145
191
  function changes() {
146
192
  if (!st || !st.lines) return [];
147
- return st.lines.filter(function (l) { return st.lib.editor.isChanged(l, st.values[l.key]) && String(st.values[l.key]).trim(); })
148
- .map(function (l) { return { key: l.key, section: l.section, was: l.text, now: String(st.values[l.key]).replace(/\s+/g, ' ').trim() }; });
193
+ return st.lines.filter(function (l) { return st.lib.editor.isChanged(l, st.values[l.key]); })
194
+ .map(function (l) {
195
+ if (removed(l)) return { key: l.key, section: l.section, was: l.text, remove: true };
196
+ return { key: l.key, section: l.section, was: l.text, now: String(st.values[l.key]).replace(/\s+/g, ' ').trim() };
197
+ });
149
198
  }
199
+ function removed(line) { return !!line && st.lib.editor.isRemoved(line, st.values[line.key]); }
150
200
  // The elements on the page whose words are one reading line: a leaf holding
151
201
  // text, inside the page's main content, not a control and not shared chrome.
152
202
  function candidates() {
@@ -178,14 +228,12 @@
178
228
  var line = byText[tidy(el.textContent)] || null;
179
229
  if (!line || used[line.key]) return;
180
230
  used[line.key] = true;
181
- st.els.push({ el: el, key: line.key, original: el.textContent });
182
- el.textContent = st.values[line.key];
183
- el.setAttribute('contenteditable', 'plaintext-only');
184
- if (el.contentEditable !== 'plaintext-only') el.setAttribute('contenteditable', 'true');
231
+ var en = { el: el, key: line.key, original: el.textContent };
232
+ st.els.push(en);
185
233
  el.setAttribute('spellcheck', 'false');
186
234
  el.setAttribute('data-pb-key', line.key);
187
235
  el.classList.add('pb-word');
188
- el.classList.toggle('pb-word--changed', st.lib.editor.isChanged(line, st.values[line.key]));
236
+ paintLine(en);
189
237
  });
190
238
  // A hint, not a ledger: a line the page shows twice is edited once (the
191
239
  // first), and its second showing is counted in neither number.
@@ -198,14 +246,44 @@
198
246
  document.addEventListener('keydown', onPageKey, true);
199
247
  document.addEventListener('paste', onPaste, true);
200
248
  document.addEventListener('focusout', onBlur, true);
249
+ document.addEventListener('focusin', onFocus, true);
201
250
  window.addEventListener('pagehide', flush);
202
251
  if (!st.els.length) {
203
252
  note('None of this page\'s words can be edited where they stand. <a href="' + esc(editorHref(st.id)) + '">Rewrite them in the tweak editor</a>.');
204
253
  return;
205
254
  }
255
+ // The first line is the one the bar offers to remove until another is
256
+ // chosen; set here, since a window without focus fires no focusin.
257
+ st.current = st.els[0].key;
206
258
  paint(editingHtml());
207
259
  st.els[0].el.focus();
208
260
  }
261
+ // One line as its state says: its words, editable; or, marked to be removed,
262
+ // the page's own words struck through, focusable but not typed into, until
263
+ // "Keep this line" puts it back. Never called mid-typing: the caret would jump.
264
+ function paintLine(en) {
265
+ var line = lineOf(en.key);
266
+ var el = en.el;
267
+ var gone = removed(line);
268
+ if (gone) {
269
+ el.textContent = en.original;
270
+ el.removeAttribute('contenteditable');
271
+ el.setAttribute('tabindex', '0');
272
+ el.setAttribute('aria-label', 'Marked to be removed: ' + tidy(line.text));
273
+ } else {
274
+ el.textContent = st.values[en.key];
275
+ el.setAttribute('contenteditable', 'plaintext-only');
276
+ if (el.contentEditable !== 'plaintext-only') el.setAttribute('contenteditable', 'true');
277
+ el.removeAttribute('tabindex');
278
+ el.removeAttribute('aria-label');
279
+ }
280
+ el.classList.toggle('pb-word--removed', gone);
281
+ el.classList.toggle('pb-word--changed', !gone && !!line && st.lib.editor.isChanged(line, st.values[en.key]));
282
+ }
283
+ function entryByKey(key) {
284
+ for (var i = 0; i < st.els.length; i++) if (st.els[i].key === key) return st.els[i];
285
+ return null;
286
+ }
209
287
  function entryOf(el) {
210
288
  var host = el && el.closest ? el.closest('[data-pb-key]') : null;
211
289
  if (!host) return null;
@@ -216,7 +294,10 @@
216
294
  var en = entryOf(e.target);
217
295
  if (!en) return;
218
296
  st.values[en.key] = en.el.textContent;
297
+ st.current = en.key;
219
298
  var line = lineOf(en.key);
299
+ // Emptied, it stays editable while the artist is still in it: they may be
300
+ // retyping. Leaving it empty marks it to be removed (onBlur).
220
301
  en.el.classList.toggle('pb-word--changed', !!line && st.lib.editor.isChanged(line, st.values[en.key]));
221
302
  if (!st.reviewing) paint(editingHtml());
222
303
  clearTimeout(st.timer);
@@ -243,7 +324,41 @@
243
324
  var text = ((e.clipboardData && e.clipboardData.getData('text/plain')) || '').replace(/\s+/g, ' ');
244
325
  document.execCommand('insertText', false, text);
245
326
  }
246
- function onBlur(e) { if (entryOf(e.target)) save(); }
327
+ function onBlur(e) {
328
+ var en = entryOf(e.target);
329
+ if (!en) return;
330
+ if (removed(lineOf(en.key)) && en.el.hasAttribute('contenteditable')) {
331
+ paintLine(en);
332
+ if (!st.reviewing) paint(editingHtml());
333
+ }
334
+ save();
335
+ }
336
+ // The line the artist is in is the one "Remove this line" acts on.
337
+ function onFocus(e) {
338
+ var en = entryOf(e.target);
339
+ if (!en || st.current === en.key) return;
340
+ st.current = en.key;
341
+ if (!st.reviewing) paint(editingHtml());
342
+ }
343
+ function removeLine() {
344
+ var en = st.current ? entryByKey(st.current) : null;
345
+ if (!en) return;
346
+ st.values[en.key] = '';
347
+ paintLine(en);
348
+ st.focusBar = true;
349
+ paint(editingHtml());
350
+ save();
351
+ }
352
+ function keepLine() {
353
+ var en = st.current ? entryByKey(st.current) : null;
354
+ var line = en && lineOf(en.key);
355
+ if (!line) return;
356
+ st.values[en.key] = line.text;
357
+ paintLine(en);
358
+ paint(editingHtml());
359
+ en.el.focus();
360
+ save();
361
+ }
247
362
 
248
363
  // The WHOLE draft each time (every changed line, the ones typed here and any
249
364
  // the editor saved), one save in flight at a time.
@@ -277,7 +392,9 @@
277
392
  en.el.removeAttribute('contenteditable');
278
393
  en.el.removeAttribute('spellcheck');
279
394
  en.el.removeAttribute('data-pb-key');
280
- en.el.classList.remove('pb-word', 'pb-word--changed');
395
+ en.el.removeAttribute('tabindex');
396
+ en.el.removeAttribute('aria-label');
397
+ en.el.classList.remove('pb-word', 'pb-word--changed', 'pb-word--removed');
281
398
  });
282
399
  st.els = [];
283
400
  st.editing = false;
@@ -287,6 +404,7 @@
287
404
  document.removeEventListener('keydown', onPageKey, true);
288
405
  document.removeEventListener('paste', onPaste, true);
289
406
  document.removeEventListener('focusout', onBlur, true);
407
+ document.removeEventListener('focusin', onFocus, true);
290
408
  window.removeEventListener('pagehide', flush);
291
409
  }
292
410
 
@@ -352,7 +470,26 @@
352
470
  restore();
353
471
  st.reviewing = false;
354
472
  st.focusBar = true;
355
- note('Sent for approval: ' + esc(plural(n, 'change', 'changes')) + '. An engineer applies the new words, and they come back to you in the studio to approve.');
473
+ st.sent = n;
474
+ paint(sentHtml(n));
475
+ });
476
+ }
477
+ // The artist review (the palette's sheet) for this same page, opened from the
478
+ // editing bar (the words are saved first, and editing carries on under it)
479
+ // or from the sent view (whose work is then done, so the bar closes).
480
+ function openNotes() {
481
+ var btn = bar && bar.querySelector('[data-pb-notes]');
482
+ if (btn) btn.disabled = true;
483
+ st.notesError = '';
484
+ var ready = st.editing ? save() : Promise.resolve(true);
485
+ ready.then(function () { return loadScript('page-notes.js', 'OTBPageNotes'); }).then(function (notes) {
486
+ if (st && !st.editing) close();
487
+ else if (btn) btn.disabled = false;
488
+ notes.open();
489
+ }, function () {
490
+ if (!st || !bar) return;
491
+ st.notesError = 'The artist review could not open. Press Review in the top bar instead.';
492
+ paint(st.editing ? editingHtml() : sentHtml(st.sent));
356
493
  });
357
494
  }
358
495
  function stop() {
@@ -374,6 +511,9 @@
374
511
  else if (btn.hasAttribute('data-pb-back')) back();
375
512
  else if (btn.hasAttribute('data-pb-send')) send();
376
513
  else if (btn.hasAttribute('data-pb-stop')) stop();
514
+ else if (btn.hasAttribute('data-pb-remove')) removeLine();
515
+ else if (btn.hasAttribute('data-pb-keep')) keepLine();
516
+ else if (btn.hasAttribute('data-pb-notes')) openNotes();
377
517
  }
378
518
  function close() {
379
519
  if (st && st.editing) { stop(); return; }
@@ -844,7 +844,9 @@
844
844
  const line = (el === greetEl && lib.matchLine(servedGreeting, wedit.lines)) || lib.matchLine(el.textContent, wedit.lines);
845
845
  if (!line) { el.setAttribute('data-unread', ''); continue; }
846
846
  wedit.keys.set(el, line.key);
847
- el.textContent = wedit.values[line.key];
847
+ // A line marked to be removed (an empty value, task 1004696) keeps its
848
+ // words on screen; the removal stays in the draft until it is typed over.
849
+ el.textContent = wedit.values[line.key] || line.text;
848
850
  el.setAttribute('contenteditable', 'true');
849
851
  el.setAttribute('spellcheck', 'false');
850
852
  el.setAttribute('aria-label', `The studio's words: ${line.text}`);
@@ -1670,6 +1670,22 @@ html.pb-editing main { padding-bottom: 180px; }
1670
1670
  .pb-word:hover { background: color-mix(in srgb, var(--craft, var(--accent)) 8%, transparent); }
1671
1671
  .pb-word:focus, .pb-word:focus-visible { background: color-mix(in srgb, var(--craft, var(--accent)) 12%, transparent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--craft, var(--accent)) 55%, transparent); }
1672
1672
  .pb-word--changed { background: color-mix(in srgb, var(--craft, var(--accent)) 14%, transparent); text-decoration-style: solid; }
1673
+ /* A line marked to be removed (task 1004696): the page's words, struck through
1674
+ and faded, still clickable so "Keep this line" can put it back. */
1675
+ .pb-word--removed {
1676
+ cursor: pointer;
1677
+ opacity: .55;
1678
+ text-decoration: line-through solid var(--ink-soft);
1679
+ text-decoration-thickness: 1px;
1680
+ }
1681
+ /* The line the artist is in, with its one action: remove it, or keep it. */
1682
+ .pb__current {
1683
+ display: flex; align-items: center; justify-content: space-between; gap: 10px;
1684
+ padding: 8px 8px 8px 12px; border-radius: var(--radius-sm); background: var(--glass);
1685
+ }
1686
+ .pb__current-words { min-width: 0; font-size: 13.5px; color: var(--ink-soft); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1687
+ .pb__btn--quiet { flex-shrink: 0; min-height: 32px; padding: 0 14px; font-size: 13px; }
1688
+ .pb__change--remove .pb__was { color: var(--ink); }
1673
1689
  @media (max-width: 640px) {
1674
1690
  .pb { bottom: calc(10px + env(safe-area-inset-bottom, 0px)); width: calc(100% - 20px); border-radius: 16px; }
1675
1691
  .pb__inner { padding: 12px 14px 14px; max-height: 64vh; }
@@ -9,10 +9,12 @@
9
9
  // sectionsOf(lines) consecutive runs of one section, as the page shows
10
10
  // them (a section that recurs gets its heading again —
11
11
  // the Word download's own rule, docx.js)
12
- // noteFor(line, value) the margin note: shared and engineer lines carry
13
- // theirs, a changed line carries "was: <old words>"
14
- // draftBody(...) the WHOLE draft the autosave PUTs: every changed,
15
- // non-empty line as { key, after }
12
+ // noteFor(line, value) the margin note: an emptied line is marked to be
13
+ // removed, shared and engineer lines carry theirs, a
14
+ // changed line carries "was: <old words>"
15
+ // draftBody(...) the WHOLE draft the autosave PUTs: every changed
16
+ // line as { key, after }, an emptied one as
17
+ // { key, remove: true }
16
18
  // counterText(...) "2 of 75 rewritten · saved"
17
19
  // liveUrl(url, loc) the page's live address, {apex} resolved from the
18
20
  // hall's own host (shell.js APEX_ORIGIN's rule)
@@ -42,27 +44,36 @@
42
44
  }
43
45
 
44
46
  // The mock's rule: a shared or engineer line always shows its own note, and
45
- // any other changed line shows the words it replaced.
47
+ // any other changed line shows the words it replaced. An emptied line says it
48
+ // will be removed first, since that is what sending it now does.
46
49
  function noteFor(line, value, notes) {
47
50
  const n = notes || NOTES;
51
+ if (isRemoved(line, value)) return { kind: 'removed', text: `to be removed · goes to an engineer · was: ${line.text}` };
48
52
  if (line.note === 'shared') return { kind: 'shared', text: n.shared || NOTES.shared };
49
53
  if (line.note === 'engineer') return { kind: 'engineer', text: n.engineer || NOTES.engineer };
50
54
  if (isChanged(line, value)) return { kind: 'was', text: `was: ${line.text}` };
51
55
  return null;
52
56
  }
53
57
 
54
- // An empty box is not a rewrite: removing words from a page is a code change
55
- // (the server's line_empty), so an emptied line is left out of the save and
56
- // the page keeps its words until the artist types new ones.
58
+ // An empty box is not a rewrite to nothing: it is the artist recommending the
59
+ // page stop showing the line (task 1004696). Removing words is a code change,
60
+ // so it is saved as { key, remove: true } (an empty `after` alone is the
61
+ // server's line_empty) and the submit hands it to an engineer. A draft line
62
+ // the server holds as a removal reads back as an empty value, so every editor
63
+ // carries it through its next save.
57
64
  function isEmpty(value) {
58
65
  return !String(value == null ? '' : value).trim();
59
66
  }
67
+ function isRemoved(line, value) {
68
+ return isChanged(line, value) && isEmpty(value);
69
+ }
60
70
 
61
71
  function draftBody(readingHash, lines, values) {
62
72
  const out = [];
63
73
  for (const l of lines || []) {
64
74
  const v = values[l.key];
65
- if (isChanged(l, v) && !isEmpty(v)) out.push({ key: l.key, after: v });
75
+ if (isRemoved(l, v)) out.push({ key: l.key, remove: true });
76
+ else if (isChanged(l, v)) out.push({ key: l.key, after: v });
66
77
  }
67
78
  return { reading_hash: readingHash, lines: out };
68
79
  }
@@ -76,11 +87,12 @@
76
87
  return `${changed} of ${total} rewritten · ${SAVE_WORDS[save] || SAVE_WORDS.saved}`;
77
88
  }
78
89
 
79
- // The editor's starting words: the page's own, with the saved draft laid over.
90
+ // The editor's starting words: the page's own, with the saved draft laid over
91
+ // (a removal as an empty value).
80
92
  function initialValues(lines, draft) {
81
93
  const values = {};
82
94
  for (const l of lines || []) values[l.key] = l.text;
83
- for (const d of (draft && draft.lines) || []) if (d && Object.prototype.hasOwnProperty.call(values, d.key)) values[d.key] = d.after;
95
+ for (const d of (draft && draft.lines) || []) if (d && Object.prototype.hasOwnProperty.call(values, d.key)) values[d.key] = d.remove ? '' : d.after;
84
96
  return values;
85
97
  }
86
98
 
@@ -248,7 +260,7 @@
248
260
  return null;
249
261
  }
250
262
 
251
- const api = { OPEN_WORDS, openChoice, clampSplit, splitFromPointer, splitFromKey, canFrame, FRAME_SIBLINGS, splitWindowFeatures, HINT, nextLineKey, confirmedCount, hintText, confirmStoreKey, parseConfirmed, NOTES, sectionsOf, isChanged, noteFor, draftBody, changedCount, counterText, initialValues, rowsFor, liveUrl, uploadedLabel, uploadRefusal, displayTitle, pageIdFrom };
263
+ const api = { OPEN_WORDS, openChoice, clampSplit, splitFromPointer, splitFromKey, canFrame, FRAME_SIBLINGS, splitWindowFeatures, HINT, nextLineKey, confirmedCount, hintText, confirmStoreKey, parseConfirmed, NOTES, sectionsOf, isChanged, isRemoved, noteFor, draftBody, changedCount, counterText, initialValues, rowsFor, liveUrl, uploadedLabel, uploadRefusal, displayTitle, pageIdFrom };
252
264
  if (typeof window !== 'undefined') window.OTBTweakEditor = api;
253
265
  if (typeof module !== 'undefined' && module.exports) module.exports = api;
254
266
  })();
@@ -45,6 +45,11 @@ Covers: every shelled hall page (`craft-mode.js`, `shell.js`, `style.css`), and
45
45
  - **The top bar fits two tools at 390px** because, at phone width, the side packs to a 4px gap, a tool is 28px, the side and the menu do not shrink, and a long page title trims with an ellipsis instead. All 34 hall pages measure 0px of sideways scroll in Artistry at 390px.
46
46
  - `tests/paintbrush.mjs` runs the real script and both shared libs over a small page.
47
47
 
48
+ ### Removing a line, and the artist review alongside (task 1004696)
49
+
50
+ - **An artist can recommend removing a line** (owner, 2026-10-09: pages carry superfluous subtitles and text). The line the artist is in shows in the bar with **Remove this line**; emptying a line's words and leaving it does the same. A removed line shows the page's own words struck through and faded, is no longer typed into but stays focusable, and **Keep this line** puts it back. Review lists it as its own "Remove" row and says it goes to an engineer. It is saved as `{ key, remove: true }` (the copy desk's rule, `modules/copy-desk/CLAUDE.md`), so the send files an engineer task to take it out.
51
+ - **The artist review is one press away**: **Artist review** in the editing bar saves the words and opens the palette's sheet (`page-notes.js`, loaded on demand like a tool) over the page; editing carries on when it closes. Once the words are sent, the bar asks "Review how this page looks too?" with **Start an artist review** (closes the bar, opens the sheet) and **Not now**. The paintbrush still writes no feedback doc itself: `tests/paintbrush.mjs` allows `page-notes.js` as the only page-notes name in the file.
52
+
48
53
  ## The lightbulb (task 1004498)
49
54
 
50
55
  - **Ideating carries one tool, the lightbulb** (`lightbulb.js`, a `TOOLS` entry loaded on first press). Its button wears its own note glyph, not the mode's lightbulb, so it is never read as the Ideating pill beside it (owner, 2026-10-04). It is a watered-down palette: the same side sheet and `.pn` styles, text only, no uploads.
@@ -6,7 +6,7 @@ Files: `tweak-editor.html` (the markup), `tweak-editor.css` (the artboard's inli
6
6
 
7
7
  - **Which page.** `?page=<page id>` (`landing:index`). With none, the page the caller holds (`GET /copy-desk/next`'s `resume`), and with nothing held, the "is in" panel's next up, so a bare link still leads somewhere. The studio (TW16) links here.
8
8
  - **One read opens it.** `GET /copy-desk/pages/:pageId/draft` (added here: `GET /copy-desk/pages/:pageId` carries neither the lines nor the draft) answers every reading line with its margin note, the open round and `held_by_you`, and the saved draft, only on the current reading (`draft_on_older_reading` otherwise). The note is the server's: `engineer` is `pages.goesToEngineer`, the submit's own rule, so a shared line with no registry row says "goes to an engineer", which is what the submit will do with it.
9
- - **The autosave is the whole draft, debounced (700ms) and on blur**, one save in flight at a time, skipped when the body equals the last accepted one; a last `keepalive` PUT on `pagehide`. An emptied box is not saved (the server's `line_empty`: removing words is a code change), and the counter reads "not saved" with the server's own words above the paper when a save is refused.
9
+ - **The autosave is the whole draft, debounced (700ms) and on blur**, one save in flight at a time, skipped when the body equals the last accepted one; a last `keepalive` PUT on `pagehide`. An emptied box is saved as a removal, `{ key, remove: true }` (task 1004696): its margin note says it is to be removed and goes to an engineer, and the submit files that engineer task (an empty `after` alone is still the server's `line_empty`), and the counter reads "not saved" with the server's own words above the paper when a save is refused.
10
10
  - **Holding decides writing.** A page nobody holds is read-only with one button, "take this page" (the claim); another builder's is read-only and names them; a submitted one is frozen. Upload and submit are the holder's only, as their routes are.
11
11
  - **Every write is named** (`WRITES`: the draft PUT, the .docx POST, the submit, the claim on a server-given `take.path`), and `tests/hall_tweak_editor.mjs` pins the list. The upload is a raw `fetch` because the generated client JSON-encodes every body.
12
12
  - **It opens full screen.** The artboard is the whole window, so the first visit sets the shell's switch (task 1004327) on; after that the shell remembers the artist's choice for this page. In full screen the switch button wears the room's glass, since the stage is dark in both themes.
@@ -0,0 +1,94 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/autopickup.js — the server's half of tweak auto-pickup
4
+ // (task 1004677 / BV2.TW19a, ADR 0371 D3, D5, D6).
5
+ //
6
+ // The Autobongos tweak lane claims a submitted page round with an `auto_pickup`
7
+ // mark (claims.auto_pickup, migration core_275). The runner is a builder's own
8
+ // process, so the limits on it are NOT left to the runner: claimTask calls
9
+ // autoPickupRefusal inside its transaction and the claim is refused when
10
+ //
11
+ // - the claim is marked but the task is not a page-tweak round (AUTO_PICKUP_NOT_A_TWEAK)
12
+ // - the project has tweak.autopickup off (AUTO_PICKUP_OFF)
13
+ // - this calendar month already holds `cap` marked claims (AUTO_PICKUP_CAP_REACHED)
14
+ // - this round already has two marked claims (AUTO_PICKUP_ROUND_LIMIT)
15
+ //
16
+ // THE LOCK. The count and the claim insert must not interleave with another
17
+ // runner's, or two runners racing for the last slot both read cap-1 and both
18
+ // insert. So the count runs after pg_advisory_xact_lock('page-tweak/autopickup'),
19
+ // one project-wide key, held to the end of claimTask's transaction (the insert
20
+ // commits under it). Only marked claims take the lock; an ordinary claim never
21
+ // waits on it.
22
+ //
23
+ // COUNTED FROM THE LEDGER, never a counter: a stored number drifts the first time
24
+ // a write fails between the claim and the bump. A released claim still counts —
25
+ // the pickup happened and the grade may already have been spent.
26
+ //
27
+ // The mark is a cost brake on a runner the owner chose to run, not a security
28
+ // boundary: a claim without it is an ordinary claim its builder could make by hand.
29
+
30
+ const api = require('../../src/module-api');
31
+
32
+ const PAGE_TWEAK_SOURCE = 'page-tweak';
33
+ const LOCK_KEY = 'page-tweak/autopickup';
34
+ // D6.2: one apply plus one try at the artist's note; a round sent back twice is a
35
+ // disagreement a person should read.
36
+ const MAX_AUTO_APPLIES_PER_ROUND = 2;
37
+
38
+ // The two counts, on any executor (the claim transaction's client, or the pool).
39
+ // Month boundaries are the database's, so every runner and every reader agree.
40
+ const MONTH_COUNT_SQL = `
41
+ SELECT count(*)::int AS n
42
+ FROM claims c JOIN tasks t ON t.id = c.task_id
43
+ WHERE c.auto_pickup IS TRUE
44
+ AND t.source = '${PAGE_TWEAK_SOURCE}'
45
+ AND c.claimed_at >= date_trunc('month', now())`;
46
+ const ROUND_COUNT_SQL = `
47
+ SELECT count(*)::int AS n FROM claims WHERE task_id = $1 AND auto_pickup IS TRUE`;
48
+
49
+ async function countThisMonth(exec = api.pool) {
50
+ const { rows } = await exec.query(MONTH_COUNT_SQL);
51
+ return Number(rows[0] && rows[0].n) || 0;
52
+ }
53
+
54
+ async function countForRound(exec, taskId) {
55
+ const { rows } = await exec.query(ROUND_COUNT_SQL, [taskId]);
56
+ return Number(rows[0] && rows[0].n) || 0;
57
+ }
58
+
59
+ // autoPickupRefusal(client, task, settings) — null when the marked claim may
60
+ // proceed, else the refusal object claimTask throws. `settings` is the resolved
61
+ // { mode, cap }, read BEFORE the transaction opens (the claimPermissionsFor
62
+ // precedent: a settings read is not part of the claim's consistency).
63
+ async function autoPickupRefusal(client, task, settings) {
64
+ if (!task || task.source !== PAGE_TWEAK_SOURCE) {
65
+ return { code: 'AUTO_PICKUP_NOT_A_TWEAK', message: 'Only a page tweak round can be claimed with the auto_pickup mark.' };
66
+ }
67
+ const s = settings || {};
68
+ if (s.mode !== 'on') {
69
+ return { code: 'AUTO_PICKUP_OFF', message: 'Tweak auto-pickup is off for this project, so a submitted page waits for a person.' };
70
+ }
71
+ await client.query('SELECT pg_advisory_xact_lock(hashtext($1))', [LOCK_KEY]);
72
+ const forRound = await countForRound(client, task.id);
73
+ if (forRound >= MAX_AUTO_APPLIES_PER_ROUND) {
74
+ return {
75
+ code: 'AUTO_PICKUP_ROUND_LIMIT', applied: forRound, limit: MAX_AUTO_APPLIES_PER_ROUND,
76
+ message: `This page round was already applied automatically ${forRound} times, so it waits for a person.`,
77
+ };
78
+ }
79
+ const used = await countThisMonth(client);
80
+ if (used >= s.cap) {
81
+ return {
82
+ code: 'AUTO_PICKUP_CAP_REACHED', used, cap: s.cap,
83
+ message: `Auto-pickup has used this month's ${s.cap} rounds, so submitted pages wait for a person until next month.`,
84
+ };
85
+ }
86
+ return null;
87
+ }
88
+
89
+ module.exports = {
90
+ LOCK_KEY,
91
+ MAX_AUTO_APPLIES_PER_ROUND,
92
+ countThisMonth,
93
+ autoPickupRefusal,
94
+ };
@@ -37,6 +37,7 @@ const { claimGate, claimPermissionsFor, disambiguateWorktreeName, foldWorktreeNa
37
37
  const { sealSessionLog } = require('./db-shared.js');
38
38
  const { gatingRebaseTasksForBuilder, shouldRefuseClaimResolve } = require('./db-ship.js');
39
39
  const { REWARD_SUGGESTION_CAP, rewardGateAssignment } = require('./db-tasks.js');
40
+ const { autoPickupRefusal } = require('./autopickup.js');
40
41
 
41
42
  // -------------------------------------------------------------------------
42
43
  // Claims (atomic)
@@ -99,8 +100,11 @@ function isRebaseGateExempt(task) {
99
100
  return Boolean(task) && task.kind === 'infra' && Number(task.priority) === 1;
100
101
  }
101
102
 
102
- async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSessionId, enforceOverlap = false }) {
103
+ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSessionId, enforceOverlap = false, autoPickup = false }) {
103
104
  const heldPermissions = await claimPermissionsFor(builderId); // BV1.R106 — before the tx opens
105
+ // ADR 0371 D5: a marked claim needs the project's switch and cap, read before the
106
+ // tx like the permissions above; the COUNT runs inside it, under the lock.
107
+ const autoPickupSettings = autoPickup ? await api.projectSettings.resolveTweakAutopickup() : null;
104
108
  const client = await pool.connect();
105
109
  try {
106
110
  await client.query('BEGIN');
@@ -142,7 +146,7 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
142
146
  }
143
147
 
144
148
  const { rows: taskRows } = await client.query(
145
- `SELECT id, status, touches, newcomer_friendly, requires_rank, needs_migration, module_key, credits_reward, reward_gate_exempt, est_minutes, kind, goal_id FROM tasks WHERE id = $1 FOR UPDATE`,
149
+ `SELECT id, status, touches, newcomer_friendly, requires_rank, needs_migration, module_key, credits_reward, reward_gate_exempt, est_minutes, kind, goal_id, source FROM tasks WHERE id = $1 FOR UPDATE`,
146
150
  [taskId]
147
151
  );
148
152
  if (taskRows.length === 0) {
@@ -350,6 +354,14 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
350
354
  }
351
355
  }
352
356
 
357
+ // ADR 0371 D5/D6 (task 1004677): the auto_pickup mark. The cap and the per-round
358
+ // limit are counted under a project-wide advisory lock that this transaction
359
+ // holds through the insert below, so two runners cannot both take the last slot.
360
+ if (autoPickup) {
361
+ const refusal = await autoPickupRefusal(client, task, autoPickupSettings);
362
+ if (refusal) throw refusal;
363
+ }
364
+
353
365
  // Pre-allocate a migration number if the task signals it'll add one.
354
366
  // Trigger: the tasks.needs_migration boolean (migration 081, touches[]
355
367
  // cleanup [1/8] / task 876). Replaced the old touches[]-contains-'migrations/'
@@ -396,9 +408,11 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
396
408
  finalWorktreeName = disambiguateWorktreeName(finalWorktreeName, taken);
397
409
  }
398
410
 
411
+ // The column is named only on a marked claim, so an ordinary claim's insert is
412
+ // byte-identical to before core_275.
399
413
  const insertClaim = await client.query(
400
- `INSERT INTO claims (task_id, builder_id, worktree_name, requested_worktree_name, head_sha, reserved_migration_number, creator_session_id)
401
- VALUES ($1, $2, $3, $4, $5, $6, $7)
414
+ `INSERT INTO claims (task_id, builder_id, worktree_name, requested_worktree_name, head_sha, reserved_migration_number, creator_session_id${autoPickup ? ', auto_pickup' : ''})
415
+ VALUES ($1, $2, $3, $4, $5, $6, $7${autoPickup ? ', TRUE' : ''})
402
416
  RETURNING id, task_id, builder_id, worktree_name, requested_worktree_name, claimed_at, head_sha, reserved_migration_number`,
403
417
  [taskId, builderId, finalWorktreeName, requestedWorktreeName, headSha ?? null, reservedMigrationNumber, creatorSessionId ?? null]
404
418
  );
@@ -276,8 +276,12 @@ module.exports = function buildClaimsRouter() {
276
276
  // an interactive single-human claim only WARNS (git merge is the real
277
277
  // collision detector — ADR 0017).
278
278
  enforce_touches: { type: 'boolean' },
279
+ // ADR 0371 D5 (task 1004677): the Autobongos tweak lane's mark. The server
280
+ // counts marked claims against the project's monthly cap and the per-round
281
+ // limit; see modules/lifecycle/autopickup.js.
282
+ auto_pickup: { type: 'boolean' },
279
283
  })) return;
280
- const { task_id: taskId, worktree_name: worktreeName, head_sha: headSha, bind_session: bindSession, enforce_touches: enforceTouches } = req.body || {};
284
+ const { task_id: taskId, worktree_name: worktreeName, head_sha: headSha, bind_session: bindSession, enforce_touches: enforceTouches, auto_pickup: autoPickup } = req.body || {};
281
285
  // Belt-and-braces — task_id must coerce to a finite number. Older calls
282
286
  // sent string ints (Number.isFinite passes both); reject NaN/garbage.
283
287
  if (!Number.isFinite(Number(taskId))) {
@@ -291,6 +295,7 @@ module.exports = function buildClaimsRouter() {
291
295
  headSha: headSha ?? null,
292
296
  creatorSessionId: resolveCreatorSessionId(bindSession, req.gdsSession?.session_id),
293
297
  enforceOverlap: enforceTouches === true,
298
+ autoPickup: autoPickup === true,
294
299
  });
295
300
  const { just_unlocked: justUnlocked, overlap_warning: overlapWarning, sibling_overlap_warning: siblingOverlapWarning, ...claim } = claimResult;
296
301
  const task = await db.getTask(Number(taskId));
@@ -375,6 +380,12 @@ module.exports = function buildClaimsRouter() {
375
380
  // block, not an authorization refusal). The refusal object already
376
381
  // carries `message` + `hint`, so no extra hint-building step needed.
377
382
  err.code === 'RELEVANCE_REVIEW_PENDING' ? 409 :
383
+ // ADR 0371 D5/D6: a marked auto-pickup claim the project's switch, monthly
384
+ // cap or per-round limit refuses. The round waits for a person.
385
+ err.code === 'AUTO_PICKUP_NOT_A_TWEAK' ? 409 :
386
+ err.code === 'AUTO_PICKUP_OFF' ? 409 :
387
+ err.code === 'AUTO_PICKUP_CAP_REACHED' ? 409 :
388
+ err.code === 'AUTO_PICKUP_ROUND_LIMIT' ? 409 :
378
389
  500;
379
390
  // V4.R14 (#952): a TASK_NOT_READY 409 names the way forward instead of
380
391
  // leaving the caller to discover the promote route by spelunking (#506).