@bongos/core 1.19.491 → 1.19.493

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.bongos-core.json +68 -43
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +4 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +151 -0
  8. package/docs/adr/README.md +2 -0
  9. package/docs/api/openapi.json +106 -3
  10. package/docs/api-reference.md +3 -2
  11. package/docs/copy-inventory.md +56 -34
  12. package/docs/copy-registry.json +237 -39
  13. package/docs/design/mocks/hall/task/v1.html +7 -11
  14. package/docs/design/mocks/hall/task/v3.html +7 -11
  15. package/docs/file-map.md +2 -1
  16. package/docs/module-api-changelog.md +4 -0
  17. package/modules/copy-desk/module.json +2 -2
  18. package/modules/copy-desk/proposals.js +278 -0
  19. package/modules/copy-desk/routes/copy-desk.js +173 -9
  20. package/modules/copy-desk/tests/copy_no_cms.mjs +90 -12
  21. package/modules/copy-desk/tests/copy_proposals.mjs +279 -0
  22. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  23. package/modules/hall-ui/public/copy-desk.css +9 -1
  24. package/modules/hall-ui/public/copy-desk.html +33 -0
  25. package/modules/hall-ui/public/copy-desk.js +134 -3
  26. package/modules/hall-ui/public/copy-desk.states.json +18 -1
  27. package/modules/hall-ui/public/task.css +71 -1
  28. package/modules/hall-ui/public/task.js +132 -13
  29. package/modules/hall-ui/public/task.states.json +1 -1
  30. package/modules/lifecycle/db-claim-reads.js +40 -0
  31. package/modules/lifecycle/db.js +4 -0
  32. package/modules/lifecycle/lifecycle.js +6 -0
  33. package/modules/lifecycle/routes/tasks.js +12 -1
  34. package/package-lock.json +2 -2
  35. package/package.json +1 -1
  36. package/scripts/gds/copy-apply.js +338 -0
  37. package/scripts/gds/copy-inventory.js +128 -30
  38. package/src/module-api.js +1 -1
  39. package/tests/hall_collab_world.mjs +24 -4
  40. package/tests/hall_record_world.mjs +183 -2
  41. package/tests/task_detail_includes.mjs +108 -0
@@ -34,6 +34,10 @@
34
34
  let queue = null;
35
35
  // The composer's current target, set when a "Flag" button is pressed.
36
36
  let target = null;
37
+ // The proposal composer's target (task 1003114 / R03), set from a "Propose
38
+ // wording" button. Kept apart from `target` because the two dialogs can carry
39
+ // different things: a flag can name a whole surface, a proposal never can.
40
+ let proposing = null;
37
41
  let closing = null;
38
42
  let dupePager = null;
39
43
 
@@ -123,8 +127,22 @@
123
127
  const note = f.status !== 'open' && f.resolution_note
124
128
  ? `<p class="cd-closed"><b>${escapeHtml(f.status)}</b> — ${escapeHtml(f.resolution_note)}</p>`
125
129
  : '';
126
- const actions = f.status === 'open' && canClose(f)
127
- ? `<div class="ov-row__actions"><button class="btn-ghost cd-close-btn" data-flag="${escapeHtml(String(f.id))}">Close…</button></div>`
130
+ // The queue is where the artist actually works, so the answer to a flag is
131
+ // offered right on it — but only for an open flag whose target is still
132
+ // there. A flag whose string has since been reworded (target_state 'gone')
133
+ // gets no propose button: there is nothing to patch, and the honest next
134
+ // step is a person deciding whether the flag is still live.
135
+ const proposable = f.status === 'open' && f.scope === 'string' && f.current
136
+ ? proposeButton({
137
+ id: f.string_id, surface: f.surface, text: f.current.text,
138
+ file: f.current.file, line: f.current.line, flag: f.id,
139
+ })
140
+ : '';
141
+ const closeable = f.status === 'open' && canClose(f)
142
+ ? `<button class="btn-ghost cd-close-btn" data-flag="${escapeHtml(String(f.id))}">Close…</button>`
143
+ : '';
144
+ const actions = proposable || closeable
145
+ ? `<div class="ov-row__actions">${proposable}${closeable}</div>`
128
146
  : '';
129
147
 
130
148
  return `<li class="ov-row" data-flag="${escapeHtml(String(f.id))}">
@@ -147,6 +165,20 @@
147
165
  data-id="${escapeHtml(d.id)}" data-surface="${escapeHtml(d.surface)}" data-text="${escapeHtml(d.text)}">Flag this…</button></div>`;
148
166
  }
149
167
 
168
+ // The propose button (task 1003114 / R03), offered ONLY where the row names one
169
+ // occurrence — file and line. That is not a UI shortcut: a proposal patches a
170
+ // specific place in a specific file, and a row that cannot say which place
171
+ // would be asking the artist to write a change nobody can land. The duplicates
172
+ // list is exactly that case, and it keeps just "Flag this…" — a sentence said
173
+ // in seven places usually wants saying once, not rewording seven times.
174
+ function proposeButton(d) {
175
+ if (!d.id || !d.file || !Number.isInteger(d.line)) return '';
176
+ return `<button class="btn-ghost cd-propose-btn"
177
+ data-id="${escapeHtml(d.id)}" data-surface="${escapeHtml(d.surface)}" data-text="${escapeHtml(d.text)}"
178
+ data-file="${escapeHtml(d.file)}" data-line="${escapeHtml(String(d.line))}"
179
+ ${d.flag ? `data-flag="${escapeHtml(String(d.flag))}"` : ''}>Propose wording…</button>`;
180
+ }
181
+
150
182
  // The duplicate's state. Colour marks the EXCEPTION here, the inverse of the
151
183
  // queue: most of this list is untouched work, so the untouched row is the
152
184
  // plain pill and colour goes to the few already in the queue or looked at.
@@ -182,7 +214,13 @@
182
214
  ${e.origin ? `<span class="ov-fact">${escapeHtml(e.origin)}</span>` : ''}
183
215
  </div>
184
216
  </div>
185
- <div class="ov-row__right">${e.flagged ? pill('fact-pill--open', 'already flagged') : flagButton(e)}</div>
217
+ <div class="ov-row__right">
218
+ <div class="ov-row__actions">
219
+ ${e.flagged ? pill('fact-pill--open', 'already flagged') : `<button class="btn-ghost cd-flag-btn"
220
+ data-id="${escapeHtml(e.id)}" data-surface="${escapeHtml(e.surface)}" data-text="${escapeHtml(e.text)}">Flag this…</button>`}
221
+ ${proposeButton(e)}
222
+ </div>
223
+ </div>
186
224
  </li>`;
187
225
  }
188
226
 
@@ -355,6 +393,85 @@
355
393
  if ($('#cd-search').value.trim()) await runSearch();
356
394
  }
357
395
 
396
+ // ---------------------------------------------------------------------------
397
+ // the proposal composer (task 1003114 / R03)
398
+ //
399
+ // The affordance the goal asked for, shaped so it does not become the thing
400
+ // the goal forbids. Nothing here writes a string: it fills in a change, the
401
+ // server turns it into a task, and the wording reaches a reader only after
402
+ // somebody claims that task and ships the diff.
403
+ // ---------------------------------------------------------------------------
404
+
405
+ const HOLE = '{…}';
406
+ const holes = (s) => String(s || '').split(HOLE).length - 1;
407
+
408
+ function openProposer(t) {
409
+ proposing = t;
410
+ $('#cd-propose-target').innerHTML = `${quote(t.text)}<br>${where(t.file, t.line)}`;
411
+ // PREFILLED with the current wording. An artist is editing a sentence, not
412
+ // composing one from nothing, and retyping it from the quote above is how a
413
+ // proposal accidentally changes a word nobody meant to change.
414
+ $('#cd-propose-text').value = t.text;
415
+ $('#cd-propose-why').value = '';
416
+ $('#cd-propose-err').textContent = '';
417
+
418
+ const n = holes(t.text);
419
+ const hint = $('#cd-propose-holes');
420
+ hint.hidden = n === 0;
421
+ if (n > 0) {
422
+ hint.textContent = `This sentence has ${n} ${HOLE} where a value is dropped in when it is shown. `
423
+ + `Keep all ${n} — move them if the new sentence needs them elsewhere, but do not remove one: `
424
+ + 'the values come from the code, and dropping one is a code change.';
425
+ }
426
+ $('#cd-propose-dialog').showModal();
427
+ }
428
+
429
+ async function submitProposal() {
430
+ const proposed = $('#cd-propose-text').value.replace(/\s+/g, ' ').trim();
431
+ const why = $('#cd-propose-why').value.trim();
432
+ const err = $('#cd-propose-err');
433
+
434
+ // The server and its floors are the real gates; these mirror them so the
435
+ // common mistakes answer instantly instead of costing a round trip.
436
+ if (!proposed) { err.textContent = 'Write the wording you want instead.'; return; }
437
+ if (proposed === String(proposing.text).replace(/\s+/g, ' ').trim()) {
438
+ err.textContent = 'That is the wording it already has. Change something, or file a flag instead if you only want to mark it.';
439
+ return;
440
+ }
441
+ if (holes(proposed) !== holes(proposing.text)) {
442
+ err.textContent = `Keep all ${holes(proposing.text)} ${HOLE} — they are the values the code drops in, and the change cannot land without them.`;
443
+ return;
444
+ }
445
+ if (why.replace(/\s+/g, ' ').length < 10) {
446
+ err.textContent = 'Say why it is better. Whoever reviews this decides it from your reason, without asking you.';
447
+ return;
448
+ }
449
+
450
+ const body = {
451
+ string_id: proposing.string_id,
452
+ proposed_text: proposed,
453
+ why,
454
+ file: proposing.file,
455
+ line: proposing.line,
456
+ };
457
+ if (proposing.flag_id) body.flag_id = Number(proposing.flag_id);
458
+
459
+ const res = await api.request('POST', `${API}/copy-desk/proposals`, { body });
460
+ if (!res.ok) {
461
+ const e = (res.data && res.data.error) || {};
462
+ const d = e.details || {};
463
+ err.textContent = d.what_this_means || d.why || e.message || 'Could not file the change.';
464
+ return;
465
+ }
466
+ $('#cd-propose-dialog').close();
467
+ const task = (res.data && res.data.task) || {};
468
+ toast(res.data && res.data.created === false
469
+ ? `Already filed — task #${task.id} carries this exact change.`
470
+ : `Filed as task #${task.id}. It lands when someone claims it.`);
471
+ await loadQueue();
472
+ if ($('#cd-search').value.trim()) await runSearch();
473
+ }
474
+
358
475
  function openCloser(id) {
359
476
  closing = queue.flags.find((f) => String(f.id) === String(id));
360
477
  if (!closing) return;
@@ -427,12 +544,26 @@
427
544
  });
428
545
  return;
429
546
  }
547
+ const proposeBtn = ev.target.closest('.cd-propose-btn');
548
+ if (proposeBtn) {
549
+ openProposer({
550
+ string_id: proposeBtn.dataset.id,
551
+ surface: proposeBtn.dataset.surface,
552
+ text: proposeBtn.dataset.text,
553
+ file: proposeBtn.dataset.file,
554
+ line: Number(proposeBtn.dataset.line),
555
+ flag_id: proposeBtn.dataset.flag || null,
556
+ });
557
+ return;
558
+ }
430
559
  const closeBtn = ev.target.closest('.cd-close-btn');
431
560
  if (closeBtn) openCloser(closeBtn.dataset.flag);
432
561
  });
433
562
 
434
563
  $('#cd-cancel').addEventListener('click', () => $('#cd-dialog').close());
435
564
  $('#cd-submit').addEventListener('click', submitFlag);
565
+ $('#cd-propose-cancel').addEventListener('click', () => $('#cd-propose-dialog').close());
566
+ $('#cd-propose-submit').addEventListener('click', submitProposal);
436
567
  $('#cd-close-cancel').addEventListener('click', () => $('#cd-close-dialog').close());
437
568
  $('#cd-close-submit').addEventListener('click', submitClose);
438
569
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "_": "Copy desk — the one-voice review queue. Its two dialogs are real <dialog>s, so their ::backdrop is the sheet's single sanctioned literal (black with alpha as a scrim). Written with the re-skin (task 1003307) so this page is MEASURED at three widths in both modes rather than looked at once; the redesign (task 1003471) put a queue, ten duplicates and a search's hits in the harness so the rows and both dialogs can be looked at.",
2
+ "_": "Copy desk — the one-voice review queue. Its three dialogs are real <dialog>s, so their ::backdrop is the sheet's single sanctioned literal (black with alpha as a scrim). Written with the re-skin (task 1003307) so this page is MEASURED at three widths in both modes rather than looked at once; the redesign (task 1003471) put a queue, ten duplicates and a search's hits in the harness so the rows and the dialogs can be looked at; task 1003114 (R03) added the proposal composer and the `proposer` state that opens it.",
3
3
  "page": "/builders/copy-desk",
4
4
  "surface": "hall-ui",
5
5
  "stub": {
@@ -102,6 +102,23 @@
102
102
  250
103
103
  ]
104
104
  ]
105
+ },
106
+ "proposer": {
107
+ "auth": true,
108
+ "actions": [
109
+ [
110
+ "wait",
111
+ 700
112
+ ],
113
+ [
114
+ "clickIfVisible",
115
+ ".cd-propose-btn"
116
+ ],
117
+ [
118
+ "wait",
119
+ 250
120
+ ]
121
+ ]
105
122
  }
106
123
  },
107
124
  "reducedMotion": {
@@ -1,6 +1,7 @@
1
1
  /* task.css — what the TASK record has that the idea record does not
2
2
  (task 1003305, goal 1000081). The shared composition is record.css; this is
3
- the page's own two shapes.
3
+ the page's own shapes: the lifecycle rail and the ship visual (1003305), the
4
+ grade panel and the session log (task 1003437).
4
5
 
5
6
  Promoted out of task.html's inline <style>, which was the last substantive
6
7
  inline block in the hall and sat at its tests/hall_audit.mjs ratchet with zero
@@ -69,3 +70,72 @@
69
70
  .tk-rail { grid-template-columns: 1fr; }
70
71
  .tk-step { padding: 12px 0 0; }
71
72
  }
73
+
74
+ /* ---- the grade panel (task 1003437) ------------------------------------- */
75
+ /* The four workers on hairlines, then the score at display weight with the
76
+ verdict pill beside it and the panel crumb after. Verification state wears
77
+ the fixed vocabulary through .rec-pill--ok/warn/danger (record.css) — never
78
+ the accent: a verdict is a fact, and the accent means pressable-or-live. */
79
+ .tk-axes {
80
+ display: grid;
81
+ grid-template-columns: repeat(4, minmax(0, 1fr));
82
+ border-top: var(--line) solid var(--rule-soft);
83
+ margin-top: var(--sp-md);
84
+ }
85
+ .tk-axis { padding: 16px 18px 18px; border-left: var(--line) solid var(--rule-soft); }
86
+ .tk-axis:first-child { border-left: 0; padding-left: 0; }
87
+ .tk-axis__n { display: block; font-family: var(--font-mono); font-size: 12px; color: var(--ink-soft); margin-bottom: 11px; }
88
+ .tk-score { display: flex; align-items: baseline; gap: 18px; flex-wrap: wrap; margin: 22px 0 0; }
89
+ .tk-score__v {
90
+ font-family: var(--font-display);
91
+ font-weight: 300;
92
+ font-size: clamp(32px, 3.8vw, 50px);
93
+ line-height: 1;
94
+ letter-spacing: -.035em;
95
+ color: var(--ink);
96
+ font-variant-numeric: tabular-nums;
97
+ }
98
+ .tk-score__of { font-size: max(.34em, 12px); font-family: var(--font-mono); color: var(--ink-faint); margin-left: .3em; }
99
+ .tk-crumb {
100
+ font-family: var(--label-face);
101
+ font-size: var(--label-size);
102
+ letter-spacing: var(--label-space);
103
+ text-transform: var(--label-case);
104
+ color: var(--ink-faint);
105
+ font-variant-numeric: tabular-nums;
106
+ }
107
+
108
+ /* ---- the session log (task 1003437) -------------------------------------- */
109
+ /* One small key/value ledger per session, newest first; a second session is
110
+ separated by a heavier rule. The handoff sits behind a disclosure so a long
111
+ note cannot push the actions off the page; the summary is a real control. */
112
+ .tk-ledger { display: grid; margin-top: var(--sp-md); }
113
+ .tk-ledger + .tk-ledger { margin-top: var(--sp-lg); border-top: 2px solid var(--rule-soft); padding-top: var(--sp-md); }
114
+ .tk-lrow {
115
+ display: grid;
116
+ grid-template-columns: 170px minmax(0, 1fr);
117
+ gap: 22px;
118
+ border-top: var(--line) solid var(--rule-soft);
119
+ padding: 13px 0;
120
+ align-items: baseline;
121
+ }
122
+ .tk-lrow:first-child { border-top: 0; padding-top: 0; }
123
+ .tk-lk {
124
+ font-family: var(--label-face);
125
+ font-size: var(--label-size);
126
+ letter-spacing: var(--label-space);
127
+ text-transform: var(--label-case);
128
+ color: var(--ink-faint);
129
+ }
130
+ .tk-lv { font-size: 15px; color: var(--ink); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
131
+ .tk-handoff > summary { cursor: pointer; min-height: 24px; font-size: 15px; color: var(--ink-soft); }
132
+ .tk-handoff .rec-md { margin-top: 10px; }
133
+
134
+ /* the claim ledger's earlier claims (task 1003437) — same list shape as Touches */
135
+ .tk-claims li { font-size: 13.5px; color: var(--ink-soft); }
136
+
137
+ @media (max-width: 640px) {
138
+ .tk-axes { grid-template-columns: repeat(2, minmax(0, 1fr)); }
139
+ .tk-axis:nth-child(3) { border-left: 0; padding-left: 0; }
140
+ .tk-lrow { grid-template-columns: 1fr; gap: 6px; }
141
+ }
@@ -330,7 +330,107 @@
330
330
  `Task description:\n${desc}`;
331
331
  }
332
332
 
333
- function renderTask(t, lastClaim, viewer, goal) {
333
+ // ---- the grade panel (task 1003437) ------------------------------------
334
+ // The adversarial panel's verdict on THIS task, on the task's own page rather
335
+ // than only on Standing's #recent-grades. Verification state wears the fixed
336
+ // vocabulary (ok / warn / danger / info) through .rec-pill, never the accent —
337
+ // a verdict is a fact about the work, not a control. The four workers ride
338
+ // grade.signals.workers[] (task_grades has no columns for them); score,
339
+ // verdict and threshold are the row's own; panel version and model come from
340
+ // the signals. A task with no grade row draws NOTHING (the ship-visual
341
+ // precedent — no placeholder, no "no grade yet" line); a row whose panel could
342
+ // not run is data, not absence, and says so in one line.
343
+ const WORKER_ORDER = ['narc', 'quality', 'hacker', 'efficiency'];
344
+ const VERDICT_TONE = { pass: 'ok', concerns: 'warn', fail: 'danger', error: 'danger' };
345
+ function verdictPill(verdict, small) {
346
+ const v = String(verdict || 'error').toLowerCase();
347
+ const tone = VERDICT_TONE[v] || 'info';
348
+ return `<span class="rec-pill rec-pill--${tone}${small ? ' rec-pill--sm' : ''}">${escapeHtml(v)}</span>`;
349
+ }
350
+ function gradePanelHtml(grade) {
351
+ if (!grade || typeof grade !== 'object') return '';
352
+ const sig = grade.signals && typeof grade.signals === 'object' ? grade.signals : {};
353
+ const head = `<section class="rec-sec" id="task-grade"><p class="rec-sec__h">Grade</p>`;
354
+ if (sig.panel_outcome === 'unavailable') {
355
+ return `${head}<p class="rec-body">The grading panel could not run when this task was shipped, so no quality signal was recorded — an outage, not a verdict.</p></section>`;
356
+ }
357
+ const workers = Array.isArray(sig.workers) ? sig.workers : [];
358
+ const kindOf = (w) => String((w && w.kind) || '').toLowerCase();
359
+ const known = WORKER_ORDER.filter((k) => workers.some((w) => kindOf(w) === k));
360
+ const extra = workers.map(kindOf).filter((k) => k && !WORKER_ORDER.includes(k));
361
+ const axes = known.concat(extra).map((k) => {
362
+ const w = workers.find((x) => kindOf(x) === k);
363
+ return `<div class="tk-axis"><span class="tk-axis__n">${escapeHtml(k)}</span>${verdictPill(w.error ? 'error' : w.verdict, true)}</div>`;
364
+ }).join('');
365
+ const score = grade.score == null || !Number.isFinite(Number(grade.score)) ? null : Number(grade.score);
366
+ const verdict = grade.passed === true ? 'pass' : grade.passed === false ? 'fail' : String(sig.panel_outcome || 'pending');
367
+ const models = [...new Set(workers.map((w) => w && w.model).filter(Boolean))];
368
+ const crumb = [
369
+ grade.threshold != null && Number.isFinite(Number(grade.threshold)) ? `threshold ${Number(grade.threshold)}` : '',
370
+ grade.grader_version ? `panel ${String(grade.grader_version)}` : '',
371
+ models.join(', '),
372
+ ].filter(Boolean).map(escapeHtml).join(' · ');
373
+ return `${head}
374
+ <p class="rec-body">The adversarial panel. Each worker reports pass, concerns or fail on its own; the gating workers must all pass before confirmed becomes ${escapeHtml(curLabel())}, and one concerns falls the task through to the manual confirm path.</p>
375
+ ${axes ? `<div class="tk-axes">${axes}</div>` : ''}
376
+ <div class="tk-score">
377
+ ${score == null ? '' : `<span class="tk-score__v">${escapeHtml(String(score))}<small class="tk-score__of">/10</small></span>`}
378
+ <span class="rec-pill rec-pill--${VERDICT_TONE[verdict] || 'info'}">verdict ${escapeHtml(verdict)}</span>
379
+ ${crumb ? `<span class="tk-crumb">${crumb}</span>` : ''}
380
+ </div>
381
+ </section>`;
382
+ }
383
+
384
+ // ---- the session log (task 1003437) ----------------------------------------
385
+ // Every session that worked THIS task — a claim seals one session_logs row at
386
+ // ship or release — on the task's own page rather than only on /sessions.
387
+ // Newest first; each is a small ledger: when and who, how long, the outcome,
388
+ // what the task paid, the handoff. The handoff is the template markdown and
389
+ // renders through the shared reader behind a disclosure, so a long note does
390
+ // not push the actions off the page. No sessions → nothing, like the visual.
391
+ // /sessions has no per-session address today, so "Full log" opens the room.
392
+ const OUTCOME_TONE = { shipped: 'ok', partial: 'warn', abandoned: 'danger' };
393
+ function fmtDuration(startIso, endIso) {
394
+ const ms = Date.parse(endIso) - Date.parse(startIso);
395
+ if (!Number.isFinite(ms) || ms < 0) return '';
396
+ const min = Math.round(ms / 60000);
397
+ if (min < 60) return `${min}m`;
398
+ const h = Math.floor(min / 60);
399
+ const m = min % 60;
400
+ return m ? `${h}h ${String(m).padStart(2, '0')}m` : `${h}h`;
401
+ }
402
+ function builderLink(row) {
403
+ const who = (row && (row.display_name || row.github_login)) || 'a builder';
404
+ if (!row || row.builder_id == null) return escapeHtml(who);
405
+ return `<a class="rec-dep" href="${hallBase()}/profile?id=${encodeURIComponent(String(row.builder_id))}">${escapeHtml(who)}</a>`;
406
+ }
407
+ function sessionLogHtml(logs, t) {
408
+ if (!Array.isArray(logs) || !logs.length) return '';
409
+ const label = curLabel();
410
+ const paidLabel = label.charAt(0).toUpperCase() + label.slice(1);
411
+ const blocks = logs.map((s) => {
412
+ const when = s.ended_at ? fmtDate(s.ended_at) : '';
413
+ const dur = fmtDuration(s.started_at, s.ended_at);
414
+ const outcome = String(s.outcome || 'unknown').toLowerCase();
415
+ const rows = [
416
+ `<div class="tk-lrow"><span class="tk-lk">Session</span><span class="tk-lv">${[escapeHtml(when), builderLink(s), escapeHtml(dur)].filter(Boolean).join(', ')}</span></div>`,
417
+ `<div class="tk-lrow"><span class="tk-lk">Outcome</span><span class="tk-lv"><span class="rec-pill rec-pill--sm rec-pill--${OUTCOME_TONE[outcome] || 'info'}">${escapeHtml(outcome)}</span></span></div>`,
418
+ ];
419
+ if (outcome === 'shipped' && t && t.credits_awarded != null) {
420
+ rows.push(`<div class="tk-lrow"><span class="tk-lk">${escapeHtml(paidLabel)} awarded</span><span class="tk-lv">${escapeHtml(String(t.credits_awarded))}</span></div>`);
421
+ }
422
+ if (s.notes_md) {
423
+ rows.push(`<div class="tk-lrow"><span class="tk-lk">Handoff</span><details class="tk-handoff"><summary>Read the handoff notes</summary><div class="rec-desc rec-md">${descriptionHtml(s.notes_md)}</div></details></div>`);
424
+ }
425
+ rows.push(`<div class="tk-lrow"><span class="tk-lk">Full log</span><span class="tk-lv"><a class="rec-dep" href="${hallBase()}/sessions">Open in Sessions</a></span></div>`);
426
+ return `<div class="tk-ledger">${rows.join('')}</div>`;
427
+ });
428
+ const count = logs.length > 1 ? ` <em>${logs.length} sessions</em>` : '';
429
+ return `<section class="rec-sec" id="task-sessions"><p class="rec-sec__h">Session log${count}</p>${blocks.join('')}</section>`;
430
+ }
431
+
432
+ function renderTask(t, lastClaim, viewer, goal, extras) {
433
+ const { grade = null, sessionLogs = null, claims = null } = extras || {};
334
434
  document.title = `#${t.id} — ${t.title || 'Task'} — ${document.title.split(' — ').pop()}`;
335
435
  const sub = $('#task-sub');
336
436
  if (sub) sub.textContent = `Task ${t.id}`;
@@ -382,17 +482,26 @@
382
482
  ]));
383
483
  parts.push(lifecycleRail(t, creditVal));
384
484
 
385
- // Claim history (task 751 — best-effort: the API surfaces the most recent
386
- // claim holder, not the full ledger of every past claim).
387
- if (lastClaim) {
388
- const who = escapeHtml(lastClaim.github_login || 'a builder');
389
- const when = lastClaim.claimed_at ? fmtDate(lastClaim.claimed_at, { time: true }) : '';
390
- const state = lastClaim.released_at
391
- ? (lastClaim.outcome ? `released, outcome ${escapeHtml(lastClaim.outcome)}` : 'released')
485
+ // Claim history (task 751). The most recent holder leads; since task 1003437
486
+ // the read also carries the FULL claim ledger (?include=claims), so a task
487
+ // worked across several claims lists the earlier ones beneath. The old
488
+ // "most recent only" caveat is gone because it is no longer true.
489
+ const ledger = Array.isArray(claims) ? claims : [];
490
+ if (lastClaim || ledger.length) {
491
+ const head = lastClaim || ledger[0];
492
+ const who = escapeHtml(head.github_login || head.display_name || 'a builder');
493
+ const when = head.claimed_at ? fmtDate(head.claimed_at, { time: true }) : '';
494
+ const state = head.released_at
495
+ ? (head.outcome ? `released, outcome ${escapeHtml(head.outcome)}` : 'released')
392
496
  : 'holding this claim';
393
- parts.push(`<section class="rec-sec"><p class="rec-sec__h">Claim</p>` +
394
- `<p class="rec-body">Last claimed by <b>${who}</b>${when ? ' on ' + escapeHtml(when) : ''}. ${state}.` +
395
- ` The task feed carries the most recent claim only, never the full ledger.</p></section>`);
497
+ const prior = ledger.slice(1).map((c) => {
498
+ const cWhen = c.claimed_at ? fmtDate(c.claimed_at, { time: true }) : '';
499
+ const cState = c.released_at ? escapeHtml(c.outcome || 'released') : 'holding';
500
+ return `<li>${builderLink(c)}${cWhen ? ' · ' + escapeHtml(cWhen) : ''} · ${cState}</li>`;
501
+ }).join('');
502
+ parts.push(`<section class="rec-sec"><p class="rec-sec__h">Claim${ledger.length > 1 ? ` <em>${ledger.length} claims</em>` : ''}</p>` +
503
+ `<p class="rec-body">Last claimed by <b>${who}</b>${when ? ' on ' + escapeHtml(when) : ''}. ${state}.</p>` +
504
+ (prior ? `<ul class="rec-files tk-claims">${prior}</ul>` : '') + '</section>');
396
505
  }
397
506
 
398
507
  if (t.value_summary) {
@@ -429,6 +538,12 @@
429
538
  `<p class="rec-body">Shipped ${escapeHtml(fmtDate(t.shipped_at))}${by}, ${escapeHtml(fmtRel(t.shipped_at))}.</p></section>`);
430
539
  }
431
540
 
541
+ // task 1003437: the grade panel and the session log — the owner's two net-new
542
+ // regions, between the record and its actions as the mocks compose them.
543
+ // Each draws nothing when it has nothing.
544
+ parts.push(gradePanelHtml(grade));
545
+ parts.push(sessionLogHtml(sessionLogs, t));
546
+
432
547
  // task 759: lifecycle action from the browser (no /builder-* CLI). The server
433
548
  // enforces every rank + ownership gate (ADR 0016); we only surface the control
434
549
  // the caller is actually allowed to use, and map any refusal to a friendly
@@ -529,7 +644,7 @@
529
644
  const id = taskIdFromPath();
530
645
  if (!id) { renderNotFound(''); return; }
531
646
  try {
532
- const d = await getJSON(`${API}/tasks/${encodeURIComponent(id)}?include=last_claim`, { softAuth: true });
647
+ const d = await getJSON(`${API}/tasks/${encodeURIComponent(id)}?include=last_claim,grade,session_logs,claims`, { softAuth: true });
533
648
  if (!d || !d.task) { renderNotFound(id); return; }
534
649
  // Best-effort viewer probe so we know which lifecycle control (if any) the
535
650
  // caller is allowed to see. A failure just means no action buttons render —
@@ -556,7 +671,11 @@
556
671
  if (ri && typeof ri.blob_base === 'string') repoBlobBase = ri.blob_base;
557
672
  if (ri && typeof ri.tree_base === 'string') repoTreeBase = ri.tree_base;
558
673
  } catch (_) { /* links degrade to the original href */ }
559
- renderTask(d.task, d.last_claim || null, viewer, goal);
674
+ renderTask(d.task, d.last_claim || null, viewer, goal, {
675
+ grade: d.grade || null,
676
+ sessionLogs: Array.isArray(d.session_logs) ? d.session_logs : null,
677
+ claims: Array.isArray(d.claims) ? d.claims : null,
678
+ });
560
679
  } catch (err) {
561
680
  if (err && err.code === AUTH_NEEDED) { toAuth(); return; }
562
681
  if (err && err.status === 404) { renderNotFound(id); return; }
@@ -7,7 +7,7 @@
7
7
  "ignoreRequests": ["/api/(gds|bongos)/"],
8
8
  "ignoreConsole": ["/api/(gds|bongos)/", "\b401\b", "\b404\b", "Unauthorized", "Not Found", "load failed"],
9
9
  "states": {
10
- "record": { "auth": true, "actions": [["wait", 600]], "expect": { "visible": ["#task-body", "#copy-prompt-btn"] } },
10
+ "record": { "auth": true, "actions": [["wait", 600]], "expect": { "visible": ["#task-body", "#task-grade", "#task-sessions", "#copy-prompt-btn"] } },
11
11
  "from-goal": { "auth": true, "url": "/task/2166?from=%2Fgoals%23%2Fgoal%2F1000081", "actions": [["wait", 600]], "expect": { "visible": ["#task-back", "#copy-prompt-btn"], "attr": [["#task-back", "href", "/goals#/goal/1000081"]] } },
12
12
  "missing": { "auth": true, "url": "/task/1003999", "actions": [["wait", 600]], "expect": { "visible": ["#task-h"] } }
13
13
  },
@@ -168,11 +168,51 @@ async function getTaskGrade(taskId) {
168
168
  return { ...row, issues: Array.isArray(fromSignals) ? fromSignals : [] };
169
169
  }
170
170
 
171
+ // getTaskClaims — the FULL claim ledger for a task, newest first (task 1003437).
172
+ // getLastClaimHolder is LIMIT 1 of this same read; the hall's task page shows
173
+ // the whole ledger so a task worked by several builders across several claims
174
+ // says so, instead of naming only the last hand. Same columns as the last-holder
175
+ // read plus the claim id, the worktree and the display name; like that read,
176
+ // deliberately NOT the holder's rank (#678). Pure read, builder-agnostic.
177
+ async function getTaskClaims(taskId) {
178
+ const { rows } = await pool.query(
179
+ `SELECT c.id AS claim_id, c.builder_id, c.claimed_at, c.released_at, c.outcome,
180
+ c.worktree_name, b.github_login, b.display_name
181
+ FROM claims c
182
+ JOIN builders b ON b.id = c.builder_id
183
+ WHERE c.task_id = $1
184
+ ORDER BY c.claimed_at DESC, c.id DESC`,
185
+ [taskId]
186
+ );
187
+ return rows;
188
+ }
189
+
190
+ // getTaskSessionLogs — every sealed session on a task, newest first (task
191
+ // 1003437). session_logs is keyed by task_id directly (a claim seals one row at
192
+ // ship or release), so there is no claim hop; joined to the builder for the
193
+ // name. notes_md is the handoff-template markdown the hall renders through its
194
+ // reader. Until this read the table had no API surface at all — write-only.
195
+ async function getTaskSessionLogs(taskId) {
196
+ const { rows } = await pool.query(
197
+ `SELECT s.id, s.builder_id, s.claim_id, s.worktree_name, s.started_at, s.ended_at,
198
+ s.outcome, s.notes_md, s.confidence,
199
+ b.github_login, b.display_name
200
+ FROM session_logs s
201
+ JOIN builders b ON b.id = s.builder_id
202
+ WHERE s.task_id = $1
203
+ ORDER BY s.ended_at DESC, s.id DESC`,
204
+ [taskId]
205
+ );
206
+ return rows;
207
+ }
208
+
171
209
  module.exports = {
172
210
  activeClaimCountForBuilder,
173
211
  getActiveClaimsForBuilder,
174
212
  getActiveClaimsForSession,
175
213
  getLastClaimHolder,
214
+ getTaskClaims,
176
215
  getTaskGrade,
216
+ getTaskSessionLogs,
177
217
  moduleScopeForActiveClaims,
178
218
  };
@@ -190,7 +190,9 @@ const {
190
190
  getActiveClaimsForBuilder,
191
191
  getActiveClaimsForSession,
192
192
  getLastClaimHolder,
193
+ getTaskClaims,
193
194
  getTaskGrade,
195
+ getTaskSessionLogs,
194
196
  moduleScopeForActiveClaims,
195
197
  } = require('./db-claim-reads.js');
196
198
  const {
@@ -330,6 +332,8 @@ module.exports = {
330
332
  getActiveClaimsForSession,
331
333
  getLastClaimHolder, // --regrade ownership guard (#678)
332
334
  getTaskGrade, // V4.R08 — diff-hash reuse check before grading
335
+ getTaskClaims, // task 1003437 — the task page's claim ledger (?include=claims)
336
+ getTaskSessionLogs, // task 1003437 — the task page's session log (?include=session_logs)
333
337
  getBuildersRoster,
334
338
  getActiveClaimsForBuilderWithStale,
335
339
  releaseClaimOnBehalf,
@@ -53,6 +53,12 @@ module.exports = {
53
53
  // nobody surfaces is merely stored.
54
54
  openTaskRecommendationsFor: (...a) => taskRecommendations.countOpenForRecipient(...a),
55
55
  getTask: (...a) => db.getTask(...a),
56
+ // findTaskBySourceRef: the create-vector idempotency read (task 1003114 / R03
57
+ // of goal 1000074). copy-desk turns an artist's copy proposal into a task and
58
+ // needs the same "this (source, source_ref) already minted a task" check the
59
+ // core POST /tasks vector does, so a double-submitted proposal resolves to the
60
+ // task it already made instead of littering the board with twins.
61
+ findTaskBySourceRef: (...a) => db.findTaskBySourceRef(...a),
56
62
  listVersions: (...a) => db.listVersions(...a),
57
63
  versionProgress: (...a) => db.versionProgress(...a),
58
64
  recentShippedTasks: (...a) => db.recentShippedTasks(...a),
@@ -254,15 +254,26 @@ module.exports = function buildTasksRouter() {
254
254
  const includes = String(req.query.include || '').split(',');
255
255
  const wantLastClaim = includes.includes('last_claim');
256
256
  const wantGrade = includes.includes('grade');
257
- const [dependencies, dependents, lastClaim, grade] = await Promise.all([
257
+ // task 1003437: the hall's task page draws a grade panel, a session log and
258
+ // the full claim ledger — two more opt-in siblings on this same read (the
259
+ // grade already rode it), so the hot path still pays for nothing it was not
260
+ // asked for. Each is ABSENT when not asked and an array (maybe empty) when
261
+ // asked, so a page can tell "none" from "not requested".
262
+ const wantSessionLogs = includes.includes('session_logs');
263
+ const wantClaims = includes.includes('claims');
264
+ const [dependencies, dependents, lastClaim, grade, sessionLogs, claims] = await Promise.all([
258
265
  db.getTaskDependencies(id),
259
266
  db.getTaskDependents(id),
260
267
  wantLastClaim ? db.getLastClaimHolder(id) : Promise.resolve(null),
261
268
  wantGrade ? db.getTaskGrade(id) : Promise.resolve(null),
269
+ wantSessionLogs ? db.getTaskSessionLogs(id) : Promise.resolve(null),
270
+ wantClaims ? db.getTaskClaims(id) : Promise.resolve(null),
262
271
  ]);
263
272
  const body = { task: { ...task, dependencies, dependents } };
264
273
  if (wantLastClaim) body.last_claim = lastClaim;
265
274
  if (wantGrade) body.grade = grade; // null when no grade exists yet
275
+ if (wantSessionLogs) body.session_logs = sessionLogs;
276
+ if (wantClaims) body.claims = claims;
266
277
  res.json(body);
267
278
  });
268
279
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.491",
3
+ "version": "1.19.493",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.491",
9
+ "version": "1.19.493",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.491",
3
+ "version": "1.19.493",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",