@bongos/core 1.19.1059 → 1.19.1061

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.
@@ -1,5 +1,6 @@
1
- // modules/copy-desk/page-status.js — the three Tweak Mode READS, derived
2
- // (task 1004316 / BV2.TW05, ADR 0341 D7 and D8).
1
+ // modules/copy-desk/page-status.js — the Tweak Mode READS, derived
2
+ // (task 1004316 / BV2.TW05, and the recommendation, task 1004318 / BV2.TW07;
3
+ // ADR 0341 D7 and D8).
3
4
  //
4
5
  // There is NO status table, by the planning session's ruling: a table would be a
5
6
  // second record of what the task ledger already says. Everything here is a pure
@@ -13,7 +14,7 @@
13
14
  // * docs/page-readings.json (what each page says now), for drift only;
14
15
  // * the caller's `tweak.page_approved:` credit rows, for the tally.
15
16
  //
16
- // The three reads:
17
+ // The four reads:
17
18
  //
18
19
  // PER PAGE status (Untweaked / Text tweaked / UI tweaked / Both) with its
19
20
  // count ("Text tweaked 2x"), the changelog, and the lines changed
@@ -22,6 +23,8 @@
22
23
  // shipped round, and drift does not lower it.
23
24
  // PER ARTIST the studio corner's tally: credits from approved pages, pages
24
25
  // tweaked, and this week's gain.
26
+ // NEXT the recommendation (TW07): the page to tweak next, its why,
27
+ // and the next few for "pick another" (composeNext, below).
25
28
  //
26
29
  // Pure: no fs, no DB, no clock (the caller passes `now`). The route owns the I/O.
27
30
 
@@ -340,8 +343,179 @@ function composeTally({ builderId, tasks, creditRows, now }) {
340
343
  };
341
344
  }
342
345
 
346
+ // ---------------------------------------------------------------------------
347
+ // The recommendation, "what should I tweak next" (task 1004318 / BV2.TW07,
348
+ // ADR 0341 D8). The fourth read, over the same ledger rows as the other three
349
+ // plus one more signal: the open page asks (flags.pageAskSummary()).
350
+ //
351
+ // WHO MAY BE RECOMMENDED A PAGE. Exactly the pages the claim route would hand
352
+ // the caller, decided by the claim's own rule (modules/lifecycle/
353
+ // page-tweak-claim.js) over the same fields, so "take next" on the answer can
354
+ // never be refused for a reason this read already knew:
355
+ //
356
+ // no open round free: the claim creates round n+1
357
+ // open, held by someone else SKIPPED (page_held)
358
+ // open, the caller's own web claim RESUME, reported apart, never a
359
+ // recommendation (the caller is already
360
+ // writing it)
361
+ // open, the caller's own CLI claim SKIPPED: they are applying it (in flight)
362
+ // open, unheld, roundOpenForWriting free: an unheld draft, taken over
363
+ // open, unheld, anything else SKIPPED (page_in_flight)
364
+ //
365
+ // THE ORDER (D8). Four tiers, and within each its own ties, then the base
366
+ // order (surface landing, builders, status, then inventory order):
367
+ //
368
+ // asked open asks: most distinct askers first, then the OLDEST ask
369
+ // changed tweaked and drifted: most changed lines first
370
+ // untweaked count 0
371
+ // tweaked tweaked with no drift, or drift unknown: last
372
+ //
373
+ // The mock's sample list puts an untweaked page before a changed one; the
374
+ // spec's order wins and the mock's list is sample data (ADR 0341 pick 8).
375
+ // ---------------------------------------------------------------------------
376
+
377
+ const NEXT_ALTERNATIVES = 4; // the mock's "pick another" row holds four
378
+ const NEXT_TIERS = Object.freeze(['asked', 'changed', 'untweaked', 'tweaked']);
379
+
380
+ function plural(n, one, many) { return `${n} ${n === 1 ? one : many}`; }
381
+
382
+ // Where the caller stands with one page, by the claim route's own rule.
383
+ function availability(rounds, builderId) {
384
+ const open = [...(rounds || [])].reverse().find((r) => !TERMINAL.has(r.status)) || null;
385
+ if (!open) return { kind: 'free', open: null };
386
+ if (open.holder_id != null) {
387
+ if (String(open.holder_id) !== String(builderId)) {
388
+ return { kind: 'held', open, holder: person(open.holder_id, open.holder_login) };
389
+ }
390
+ return open.holder_worktree ? { kind: 'in_flight', open } : { kind: 'resume', open };
391
+ }
392
+ return pages.roundOpenForWriting(open) ? { kind: 'free', open } : { kind: 'in_flight', open };
393
+ }
394
+
395
+ function surfaceWord(id) {
396
+ return (SURFACE_LABELS[id] || id).toLowerCase();
397
+ }
398
+
399
+ function candidateFor({ page, rounds, readingPage, readingsOk, ask, builderId, avail }) {
400
+ const counts = countsFor(rounds);
401
+ const drift = driftFor(readingPage, rounds, readingsOk);
402
+ const askers = ask ? ask.askers : 0;
403
+ // "Lines changed": a reworded line is one line that differs on each side of
404
+ // the multiset, so it counts once; a line only added, or only removed, still
405
+ // counts. The larger side is that number.
406
+ const changedLines = drift.known && drift.changed ? Math.max(drift.changed_count, drift.removed_count) : 0;
407
+ const last = latestShipped(rounds);
408
+ const yours = !!last && last.artist_id != null && String(last.artist_id) === String(builderId);
409
+
410
+ const parts = [];
411
+ if (askers > 0) parts.push(`${plural(askers, 'person', 'people')} asked for it`);
412
+ if (counts.count === 0) parts.push('untweaked');
413
+ else if (changedLines > 0) parts.push(`${plural(changedLines, 'line', 'lines')} changed since ${yours ? 'your' : 'the'} last pass`);
414
+ else parts.push(drift.known ? `${counts.status_label.toLowerCase()}, nothing changed since` : counts.status_label.toLowerCase());
415
+
416
+ let tier = 'tweaked';
417
+ if (askers > 0) tier = 'asked';
418
+ else if (changedLines > 0) tier = 'changed';
419
+ else if (counts.count === 0) tier = 'untweaked';
420
+
421
+ return {
422
+ page_id: page.id,
423
+ surface: page.surface,
424
+ title: page.title || page.id,
425
+ path: page.path || null,
426
+ tier,
427
+ why: parts[0],
428
+ why_line: [surfaceWord(page.surface), ...parts].join(' · '),
429
+ askers,
430
+ oldest_ask_at: ask ? iso(ask.oldest_ask_at) : null,
431
+ changed_lines: changedLines,
432
+ drift_known: drift.known,
433
+ count: counts.count,
434
+ status_label: counts.status_label,
435
+ // An unheld draft the claim takes over, so the take is honest about it.
436
+ draft_round: avail.open ? { task_id: String(avail.open.id), round: avail.open.round } : null,
437
+ take: { method: 'POST', path: `/copy-desk/pages/${page.id}/claim` },
438
+ };
439
+ }
440
+
441
+ function compareCandidates(a, b) {
442
+ const t = NEXT_TIERS.indexOf(a.tier) - NEXT_TIERS.indexOf(b.tier);
443
+ if (t) return t;
444
+ if (a.tier === 'asked') {
445
+ if (a.askers !== b.askers) return b.askers - a.askers;
446
+ const at = a.oldest_ask_at ? Date.parse(a.oldest_ask_at) : Infinity;
447
+ const bt = b.oldest_ask_at ? Date.parse(b.oldest_ask_at) : Infinity;
448
+ if (at !== bt) return at - bt;
449
+ }
450
+ if (a.tier === 'changed' && a.changed_lines !== b.changed_lines) return b.changed_lines - a.changed_lines;
451
+ return a._base - b._base;
452
+ }
453
+
454
+ // GET /copy-desk/next — the recommended page for `builderId`, its why, and
455
+ // the next few for "pick another". `asks` is flags.pageAskSummary()'s rows.
456
+ function composeNext({ inventory, readings, tasks, asks, builderId, alternatives = NEXT_ALTERNATIVES }) {
457
+ const byPage = indexRounds(tasks);
458
+ const readingsOk = !!readings;
459
+ const rIdx = readingIndex(readings);
460
+ const askIdx = new Map();
461
+ for (const a of asks || []) if (a && a.page_id && Number(a.askers) > 0) askIdx.set(a.page_id, { askers: Number(a.askers), oldest_ask_at: a.oldest_ask_at });
462
+
463
+ const surfaceRank = new Map(orderedSurfaces(inventory).map((id, i) => [id, i]));
464
+ const invPages = ((inventory && inventory.pages) || []).filter((p) => p && p.id);
465
+ const base = new Map(invPages
466
+ .map((p, i) => ({ id: p.id, s: surfaceRank.has(p.surface) ? surfaceRank.get(p.surface) : surfaceRank.size, i }))
467
+ .sort((a, b) => a.s - b.s || a.i - b.i)
468
+ .map((x, k) => [x.id, k]));
469
+
470
+ let resume = null;
471
+ const skipped = [];
472
+ const candidates = [];
473
+ for (const page of invPages) {
474
+ const rounds = byPage.get(page.id) || [];
475
+ const avail = availability(rounds, builderId);
476
+ if (avail.kind === 'resume') {
477
+ resume = {
478
+ page_id: page.id, surface: page.surface, title: page.title || page.id, path: page.path || null,
479
+ task_id: String(avail.open.id), round: avail.open.round,
480
+ take: { method: 'POST', path: `/copy-desk/pages/${page.id}/claim` },
481
+ };
482
+ continue;
483
+ }
484
+ if (avail.kind === 'held' || avail.kind === 'in_flight') {
485
+ skipped.push({
486
+ page_id: page.id,
487
+ reason: avail.kind === 'held' ? 'page_held' : 'page_in_flight',
488
+ state: roundState(avail.open),
489
+ ...(avail.holder ? { holder: avail.holder } : {}),
490
+ });
491
+ continue;
492
+ }
493
+ const c = candidateFor({ page, rounds, readingPage: rIdx.get(page.id), readingsOk, ask: askIdx.get(page.id), builderId, avail });
494
+ c._base = base.get(page.id);
495
+ candidates.push(c);
496
+ }
497
+ candidates.sort(compareCandidates);
498
+ for (const c of candidates) delete c._base;
499
+
500
+ const n = Math.max(0, Math.floor(Number(alternatives) || 0));
501
+ return {
502
+ builder_id: String(builderId),
503
+ resume,
504
+ recommended: candidates[0] || null,
505
+ alternatives: candidates.slice(1, 1 + n),
506
+ candidates: candidates.length,
507
+ // "4 pages have been asked for" (the mock's Artist Review Status line):
508
+ // every inventory page with an open ask, held or not.
509
+ asked_pages: invPages.filter((p) => askIdx.has(p.id)).length,
510
+ skipped,
511
+ };
512
+ }
513
+
343
514
  module.exports = {
344
515
  TALLY_REASON_PREFIX,
516
+ NEXT_ALTERNATIVES,
517
+ NEXT_TIERS,
518
+ composeNext,
345
519
  SURFACE_ORDER,
346
520
  SURFACE_LABELS,
347
521
  STATUS,
@@ -215,6 +215,196 @@ function roundOpenForWriting(task) {
215
215
  return !!task && task.status === 'ready' && !blockPresence(task.description).batch;
216
216
  }
217
217
 
218
+ // ---------------------------------------------------------------------------
219
+ // The draft and the submit (task 1004319 / BV2.TW08, ADR 0341 D4).
220
+ //
221
+ // Both resolve every TARGET from the page reading (docs/page-readings.json),
222
+ // never from the request body (ADR 0233 §7). A draft save sends only
223
+ // { reading_hash, lines: [{ key, after }] }; the line's section, its current
224
+ // text (`before`) and its placement are copied from the reading. The submit
225
+ // re-resolves each draft line against the CURRENT reading, so a line the page
226
+ // no longer shows is refused by name (`target_gone`) instead of being frozen
227
+ // against a sentence that is not there.
228
+ // ---------------------------------------------------------------------------
229
+
230
+ // The interpolation hole the inventory writes for `${...}` (proposals.js's
231
+ // PLACEHOLDER; restated here because this file requires nothing).
232
+ const PLACEHOLDER = '{…}';
233
+ // One rewritten line. The longest line any page shows today is 760 characters,
234
+ // so 1000 leaves room to rewrite it and still bounds a draft.
235
+ const MAX_LINE_CHARS = 1000;
236
+ // A draft holds changed lines only, and the longest page has under 500 lines.
237
+ const MAX_DRAFT_LINES = 1000;
238
+ const UNPLACED_SOURCE = 'page-tweak-unplaced';
239
+ const LINE_KEY_RE = /^L[0-9]{4,}$/;
240
+
241
+ // The inventory's own whitespace rule (proposals.js tidy): a line is measured
242
+ // and compared in the form the reading records it.
243
+ function tidy(raw) {
244
+ return String(raw == null ? '' : raw).replace(/\s+/g, ' ').trim();
245
+ }
246
+
247
+ function placeholderCount(text) {
248
+ return String(text || '').split(PLACEHOLDER).length - 1;
249
+ }
250
+
251
+ // Replace the ONE block of a fence in a description, or append it when there is
252
+ // none. A description carrying two is refused rather than guessed at.
253
+ function replaceBlock(markdown, fence, block) {
254
+ const src = String(markdown || '');
255
+ const re = new RegExp('```' + fence.replace(/-/g, '\\-') + '[ \\t]*\\r?\\n[\\s\\S]*?\\r?\\n```', 'g');
256
+ const hits = src.match(re) || [];
257
+ if (hits.length > 1) return fail('multiple_blocks', { fence, count: hits.length });
258
+ if (hits.length === 1) return { ok: true, description: src.replace(re, () => block) };
259
+ return { ok: true, description: src.trimEnd() + (src.trim() ? '\n\n' : '') + block };
260
+ }
261
+
262
+ // resolveDraft({ readingPage, body, savedAt }) -> { ok, draft } | refusal
263
+ //
264
+ // A WHOLE-DRAFT replace: the body is every changed line the artist has, and the
265
+ // draft becomes exactly that. A line whose `after` equals the page's current
266
+ // text is not a change and is dropped, so only changed lines are stored (D4).
267
+ // Placeholder arithmetic is NOT checked here: an autosave lands mid-sentence,
268
+ // and the check belongs to the submit (and again to the applier).
269
+ function resolveDraft({ readingPage, body, savedAt }) {
270
+ if (!readingPage || !Array.isArray(readingPage.lines)) return fail('reading_missing');
271
+ const b = body || {};
272
+ if (String(b.reading_hash || '') !== String(readingPage.reading_hash || '')) {
273
+ return fail('reading_moved', { reading_hash: readingPage.reading_hash || null, sent: b.reading_hash || null });
274
+ }
275
+ const list = Array.isArray(b.lines) ? b.lines : [];
276
+ if (list.length > MAX_DRAFT_LINES) return fail('too_many_lines', { max: MAX_DRAFT_LINES, was: list.length });
277
+ const byKey = new Map(readingPage.lines.map((l) => [l.key, l]));
278
+ const seen = new Set();
279
+ const unknown = [];
280
+ const tooLong = [];
281
+ const empty = [];
282
+ const out = [];
283
+ for (const raw of list) {
284
+ 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 });
286
+ if (seen.has(key)) return fail('duplicate_line', { key });
287
+ seen.add(key);
288
+ const at = byKey.get(key);
289
+ if (!at) { unknown.push(key); continue; }
290
+ const after = tidy(raw.after);
291
+ if (after === tidy(at.text)) continue;
292
+ if (!after) { empty.push(key); continue; }
293
+ if (after.length > MAX_LINE_CHARS) { tooLong.push(key); continue; }
294
+ out.push({ key, section: at.section == null ? null : at.section, before: at.text, after, placement: at.placement });
295
+ }
296
+ if (unknown.length) return fail('unknown_line', { keys: unknown });
297
+ if (empty.length) return fail('line_empty', { keys: empty });
298
+ if (tooLong.length) return fail('line_too_long', { keys: tooLong, max: MAX_LINE_CHARS });
299
+ // Page order, whatever order the editor sent them in.
300
+ const order = new Map(readingPage.lines.map((l, i) => [l.key, i]));
301
+ out.sort((x, y) => order.get(x.key) - order.get(y.key));
302
+ return { ok: true, draft: { page_id: readingPage.id, reading_hash: readingPage.reading_hash, saved_at: savedAt, lines: out } };
303
+ }
304
+
305
+ // Is a newly resolved draft the one already saved? Then the save writes
306
+ // nothing: an autosave that changed nothing must not touch the task.
307
+ function sameDraft(saved, next) {
308
+ if (!saved || !next) return false;
309
+ 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])]);
311
+ return pick(saved) === pick(next);
312
+ }
313
+
314
+ // Find a draft line on the CURRENT reading: the same key showing the same text,
315
+ // else the one line showing that text in the same section, else the one line
316
+ // showing it anywhere on the page. Anything else is gone, or too ambiguous to
317
+ // pick for the artist.
318
+ function locateLine(lines, draftLine) {
319
+ const same = (l) => l.text === draftLine.before;
320
+ const byKey = lines.find((l) => l.key === draftLine.key);
321
+ if (byKey && same(byKey)) return { line: byKey };
322
+ const inSection = lines.filter((l) => same(l) && l.section === draftLine.section);
323
+ if (inSection.length === 1) return { line: inSection[0] };
324
+ const anywhere = lines.filter(same);
325
+ if (anywhere.length === 1) return { line: anywhere[0] };
326
+ return { code: anywhere.length > 1 ? 'ambiguous_target' : 'target_gone' };
327
+ }
328
+
329
+ // A line the applier cannot place goes to an engineer (spec decision 7). That
330
+ // is every `unplaced` line, and a `shared` line the registry has no row for:
331
+ // shell text with no file behind it cannot be applied mechanically either.
332
+ function goesToEngineer(line) {
333
+ return line.placement === 'unplaced' || (line.placement === 'shared' && !line.file);
334
+ }
335
+
336
+ // freezeBatch({ readingPage, draft, submittedAt }) -> { ok, batch, unplaced } | refusal
337
+ //
338
+ // The submit's pure half. Every draft line is re-resolved on the current
339
+ // reading; a refused line (`target_gone`, `ambiguous_target`,
340
+ // `placeholder_mismatch`) refuses the submit, naming each line, and nothing is
341
+ // frozen: the artist fixes those lines and submits again. `batch` is the
342
+ // `page-tweak` block's content without its `unplaced_tasks` (the lifecycle
343
+ // transaction fills those in once the engineer tasks exist). `unplaced` is the
344
+ // lines taken out of it.
345
+ function freezeBatch({ readingPage, draft, submittedAt }) {
346
+ if (!readingPage || !Array.isArray(readingPage.lines)) return fail('reading_missing');
347
+ if (!draft || !Array.isArray(draft.lines) || !draft.lines.length) return fail('nothing_to_submit');
348
+ const refused = [];
349
+ const placed = [];
350
+ const unplaced = [];
351
+ const keys = new Set();
352
+ for (const d of draft.lines) {
353
+ const found = locateLine(readingPage.lines, d);
354
+ if (!found.line) { refused.push({ key: d.key, section: d.section == null ? null : d.section, before: d.before, code: found.code }); continue; }
355
+ const at = found.line;
356
+ if (keys.has(at.key)) { refused.push({ key: d.key, section: d.section == null ? null : d.section, before: d.before, code: 'ambiguous_target' }); continue; }
357
+ keys.add(at.key);
358
+ const line = { key: at.key, section: at.section == null ? null : at.section, before: at.text, after: d.after, placement: at.placement };
359
+ if (goesToEngineer(at)) { unplaced.push(line); continue; }
360
+ const want = placeholderCount(at.text);
361
+ const got = placeholderCount(d.after);
362
+ if (want !== got) { refused.push({ key: at.key, section: line.section, before: at.text, code: 'placeholder_mismatch', expected: want, got }); continue; }
363
+ placed.push({ ...line, file: at.file, line: at.line == null ? null : at.line, string_id: at.string_id == null ? null : at.string_id });
364
+ }
365
+ if (refused.length) return fail('lines_refused', { refused });
366
+ const order = new Map(readingPage.lines.map((l, i) => [l.key, i]));
367
+ const byOrder = (x, y) => order.get(x.key) - order.get(y.key);
368
+ return {
369
+ ok: true,
370
+ batch: { page_id: readingPage.id, reading_hash: readingPage.reading_hash, submitted_at: submittedAt, lines: placed.sort(byOrder) },
371
+ unplaced: unplaced.sort(byOrder),
372
+ };
373
+ }
374
+
375
+ // The engineer task an unplaced rewrite files (ADR 0341 D4): one per line,
376
+ // keyed so a repeated submit finds the task it already filed.
377
+ function unplacedSourceRef(roundTaskId, key) {
378
+ return `${UNPLACED_SOURCE}/${roundTaskId}/${key}`;
379
+ }
380
+
381
+ function clip(text, n) {
382
+ const s = String(text || '');
383
+ return s.length > n ? `${s.slice(0, n - 1)}…` : s;
384
+ }
385
+
386
+ function unplacedTaskTitle(page, line) {
387
+ return `Place and reword "${clip(line.before, 60)}" (${page.id})`;
388
+ }
389
+
390
+ function unplacedTaskBody(page, line, roundTaskId) {
391
+ const where = line.placement === 'shared'
392
+ ? 'It is shared shell text (it shows on every page of the surface), and the copy registry has no row for it.'
393
+ : '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).';
394
+ return [
395
+ `An artist rewrote a line on **${String(page.title || page.id)}** (\`${page.id}\`${page.path ? `, ${page.path}` : ''}) that the applier cannot change mechanically, so it comes to an engineer (ADR 0341 D4, spec decision 7).`,
396
+ '',
397
+ where,
398
+ '',
399
+ `- Section: ${line.section == null ? '(none)' : line.section}`,
400
+ `- Line: ${line.key} in the page reading`,
401
+ `- It says now: ${JSON.stringify(line.before)}`,
402
+ `- The artist's wording: ${JSON.stringify(line.after)}`,
403
+ '',
404
+ `Find where this text is rendered and change it to the artist's wording, or make it a string the copy registry can place so the next tweak can change it directly. The page's tweak round (task ${roundTaskId}) does not wait for this.`,
405
+ ].join('\n');
406
+ }
407
+
218
408
  module.exports = {
219
409
  SCHEMA,
220
410
  SOURCE,
@@ -235,4 +425,14 @@ module.exports = {
235
425
  parseSentBackBlocks,
236
426
  blockPresence,
237
427
  roundOpenForWriting,
428
+ MAX_LINE_CHARS,
429
+ MAX_DRAFT_LINES,
430
+ UNPLACED_SOURCE,
431
+ replaceBlock,
432
+ resolveDraft,
433
+ sameDraft,
434
+ freezeBatch,
435
+ unplacedSourceRef,
436
+ unplacedTaskTitle,
437
+ unplacedTaskBody,
238
438
  };