@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.
- package/.bongos-core.json +72 -52
- package/.claude/skills/goal-create/SKILL.md +8 -3
- package/.claude/skills/idea-triage/SKILL.md +1 -1
- package/.claude/skills/planning-session/SKILL.md +6 -4
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +6 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +91 -2
- package/docs/api-reference.md +5 -3
- package/docs/copy-inventory.md +25 -19
- package/docs/copy-registry.json +92 -37
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +189 -188
- package/migrations/core_275_claims_auto_pickup.sql +24 -0
- package/modules/copy-desk/docx.js +27 -7
- package/modules/copy-desk/page-status.js +3 -1
- package/modules/copy-desk/pages.js +44 -5
- package/modules/copy-desk/routes/copy-desk.js +4 -3
- package/modules/copy-desk/tests/copy_docx.mjs +38 -0
- package/modules/copy-desk/tests/copy_page_draft.mjs +79 -0
- package/modules/government/decision-rules.js +1 -0
- package/modules/hall-ui/public/paintbrush.js +152 -12
- package/modules/hall-ui/public/studio.js +3 -1
- package/modules/hall-ui/public/style.css +16 -0
- package/modules/hall-ui/public/tweak-editor-lib.js +24 -12
- package/modules/hall-ui/records/craft-mode.md +5 -0
- package/modules/hall-ui/records/tweak-editor.md +1 -1
- package/modules/lifecycle/autopickup.js +94 -0
- package/modules/lifecycle/db-claims.js +18 -4
- package/modules/lifecycle/routes/claims.js +12 -1
- package/modules/lifecycle/routes/project-settings.js +59 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/government-walk.js +1 -0
- package/scripts/gds/run-unit-tests.js +3 -0
- package/scripts/gds/seed-bv2-ideator-surface-tasks.js +12 -21
- package/scripts/gds/seed-verify-criteria.js +73 -18
- package/src/bongos/project-settings.js +79 -0
- package/src/module-api.js +8 -1
- package/tests/autopickup_cap.mjs +236 -0
- package/tests/autopickup_cap_db.mjs +115 -0
- package/tests/copy_desk_page_submit.mjs +36 -0
- package/tests/government_act_alone_buildings.mjs +1 -0
- package/tests/hall_tweak_editor.mjs +19 -3
- package/tests/paintbrush.mjs +151 -2
- package/tests/planning_session_skill.mjs +40 -0
- 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
|
|
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])
|
|
148
|
-
.map(function (l) {
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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) {
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
|
55
|
-
//
|
|
56
|
-
//
|
|
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 (
|
|
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
|
|
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).
|