@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.
- package/.bongos-core.json +68 -43
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +151 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +106 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +56 -34
- package/docs/copy-registry.json +237 -39
- package/docs/design/mocks/hall/task/v1.html +7 -11
- package/docs/design/mocks/hall/task/v3.html +7 -11
- package/docs/file-map.md +2 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/copy-desk/module.json +2 -2
- package/modules/copy-desk/proposals.js +278 -0
- package/modules/copy-desk/routes/copy-desk.js +173 -9
- package/modules/copy-desk/tests/copy_no_cms.mjs +90 -12
- package/modules/copy-desk/tests/copy_proposals.mjs +279 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/hall-ui/public/copy-desk.css +9 -1
- package/modules/hall-ui/public/copy-desk.html +33 -0
- package/modules/hall-ui/public/copy-desk.js +134 -3
- package/modules/hall-ui/public/copy-desk.states.json +18 -1
- package/modules/hall-ui/public/task.css +71 -1
- package/modules/hall-ui/public/task.js +132 -13
- package/modules/hall-ui/public/task.states.json +1 -1
- package/modules/lifecycle/db-claim-reads.js +40 -0
- package/modules/lifecycle/db.js +4 -0
- package/modules/lifecycle/lifecycle.js +6 -0
- package/modules/lifecycle/routes/tasks.js +12 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/copy-apply.js +338 -0
- package/scripts/gds/copy-inventory.js +128 -30
- package/src/module-api.js +1 -1
- package/tests/hall_collab_world.mjs +24 -4
- package/tests/hall_record_world.mjs +183 -2
- 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
|
-
|
|
127
|
-
|
|
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"
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
386
|
-
//
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
};
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|