@bongos/core 1.19.491 → 1.19.492
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 +27 -22
- package/docs/copy-inventory.md +33 -23
- package/docs/copy-registry.json +118 -28
- package/docs/design/mocks/hall/task/v1.html +7 -11
- package/docs/design/mocks/hall/task/v3.html +7 -11
- package/docs/module-api-changelog.md +2 -0
- 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/routes/tasks.js +12 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/hall_record_world.mjs +183 -2
- package/tests/task_detail_includes.mjs +108 -0
|
@@ -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,
|
|
@@ -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.492",
|
|
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.492",
|
|
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.492",
|
|
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",
|
package/src/module-api.js
CHANGED
|
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
45
45
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
46
46
|
// the entry to that file. Look for a version's history there, not here.
|
|
47
47
|
// ---------------------------------------------------------------------------
|
|
48
|
-
const CORE_VERSION = '1.19.
|
|
48
|
+
const CORE_VERSION = '1.19.492'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
49
49
|
|
|
50
50
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
51
51
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -38,6 +38,10 @@
|
|
|
38
38
|
// Reproduce the renders these came from (the RECORD pages need a loaded record,
|
|
39
39
|
// which the kit's own stub has no feed for — so they run against the hall's
|
|
40
40
|
// fixture harness):
|
|
41
|
+
// * THE GRADE PANEL AND THE SESSION LOG (task 1003437) ride the one task read
|
|
42
|
+
// behind ?include=, draw nothing when absent, and wear the fixed verification
|
|
43
|
+
// vocabulary — never the accent. The claim block shows the whole ledger.
|
|
44
|
+
//
|
|
41
45
|
// node scripts/hall-preview/server.js --port 4612 --fixture-me
|
|
42
46
|
// node modules/ui-design/kit/render.js --states modules/hall-ui/public/task.states.json \
|
|
43
47
|
// --base-in http://builders.localhost:4612 --base-out http://builders.localhost:4612
|
|
@@ -262,7 +266,11 @@ const TASK_SRC = read('task.js');
|
|
|
262
266
|
// does the same: the description renders through it.
|
|
263
267
|
const MD_SRC = read('md-reader.js');
|
|
264
268
|
|
|
265
|
-
|
|
269
|
+
// grade / sessionLogs / claims (task 1003437): left undefined, the stub answers
|
|
270
|
+
// like a client that never asked (the key is absent); null or an array answers
|
|
271
|
+
// like the read did. `requests` records every path the page asked for.
|
|
272
|
+
function renderTask(task, { lastClaim = null, viewer = null, goal = null, status = 200, search = '', referrer = '', withReader = true, grade, sessionLogs, claims } = {}) {
|
|
273
|
+
const requests = [];
|
|
266
274
|
const els = new Map();
|
|
267
275
|
const makeEl = () => ({
|
|
268
276
|
dataset: {}, style: {}, textContent: '', hidden: false, disabled: false, _html: '',
|
|
@@ -287,7 +295,15 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, status
|
|
|
287
295
|
};
|
|
288
296
|
const client = {
|
|
289
297
|
request: async (method, p) => {
|
|
290
|
-
|
|
298
|
+
requests.push(p);
|
|
299
|
+
if (/\/tasks\/\d+/.test(p)) {
|
|
300
|
+
if (status !== 200) return { status, ok: false, data: null };
|
|
301
|
+
const data = { task, last_claim: lastClaim };
|
|
302
|
+
if (grade !== undefined) data.grade = grade;
|
|
303
|
+
if (sessionLogs !== undefined) data.session_logs = sessionLogs;
|
|
304
|
+
if (claims !== undefined) data.claims = claims;
|
|
305
|
+
return { status: 200, ok: true, data };
|
|
306
|
+
}
|
|
291
307
|
if (/\/me$/.test(p)) return { status: 200, ok: true, data: { builder: viewer } };
|
|
292
308
|
if (/\/goals\//.test(p)) return { status: 200, ok: true, data: { goal } };
|
|
293
309
|
return { status: 404, ok: false, data: null };
|
|
@@ -322,6 +338,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, status
|
|
|
322
338
|
html: () => documentObj.getElementById('task-body').innerHTML,
|
|
323
339
|
sub: () => documentObj.getElementById('task-sub').textContent,
|
|
324
340
|
el: (id) => documentObj.getElementById(id),
|
|
341
|
+
requests,
|
|
325
342
|
};
|
|
326
343
|
}
|
|
327
344
|
|
|
@@ -569,3 +586,167 @@ test('the description still reads when the reader is absent', async () => {
|
|
|
569
586
|
assert.match(r.html(), /<div class="rec-desc rec-md"><p>plain <b>text<\/b><\/p><\/div>/,
|
|
570
587
|
'escaped into one paragraph — a missing script must never blank the record');
|
|
571
588
|
});
|
|
589
|
+
|
|
590
|
+
// ---- the grade panel and the session log (task 1003437) ----------------------
|
|
591
|
+
// Two NET-NEW regions the owner approved at the hall composition round (direction
|
|
592
|
+
// doc §6 decision 4). Both ride the task read's ?include= opt-in, so the page asks
|
|
593
|
+
// once; both draw NOTHING when absent (the ship-visual precedent), and a verdict
|
|
594
|
+
// wears the fixed verification vocabulary, never the accent.
|
|
595
|
+
|
|
596
|
+
const GRADE = {
|
|
597
|
+
passed: true, score: '8.5', threshold: '6.0', grader_kind: 'subagent', grader_version: 'v4.0',
|
|
598
|
+
rubric_scores: {}, notes_md: '', issues: [],
|
|
599
|
+
signals: {
|
|
600
|
+
grader_version: 'v4.0', threshold: 6, panel_outcome: 'pass',
|
|
601
|
+
workers: [
|
|
602
|
+
{ kind: 'narc', name: 'Narc', verdict: 'pass', model: 'claude-sonnet-5' },
|
|
603
|
+
{ kind: 'quality', name: 'Quality', verdict: 'pass', model: 'claude-sonnet-5' },
|
|
604
|
+
{ kind: 'hacker', name: 'Hacker', verdict: 'pass', model: 'claude-sonnet-5' },
|
|
605
|
+
{ kind: 'efficiency', name: 'Efficiency Monger', verdict: 'pass', model: 'claude-sonnet-5' },
|
|
606
|
+
],
|
|
607
|
+
},
|
|
608
|
+
};
|
|
609
|
+
const SESSION = {
|
|
610
|
+
id: '1', builder_id: '95', github_login: 'jaxri', display_name: 'Percy Jackson',
|
|
611
|
+
started_at: '2026-08-30T17:41:00Z', ended_at: '2026-08-30T18:18:00Z', outcome: 'shipped',
|
|
612
|
+
notes_md: '**What shipped:**\n- the thing', confidence: 'verified-smoke',
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
test('the page asks the task feed for the grade, the session log and the claim ledger in the one read', async () => {
|
|
616
|
+
const r = renderTask(BASE_TASK);
|
|
617
|
+
await r.boot(); await settle();
|
|
618
|
+
const reads = r.requests.filter((p) => /\/tasks\/\d+/.test(p));
|
|
619
|
+
assert.equal(reads.length, 1, 'one read, not a fetch per region');
|
|
620
|
+
for (const k of ['last_claim', 'grade', 'session_logs', 'claims']) {
|
|
621
|
+
assert.match(reads[0], new RegExp(`include=[^&]*\\b${k}\\b`), `asks for ${k}`);
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test('the grade panel: four workers as verification pills, the score out of ten, the verdict, the panel crumb', async () => {
|
|
626
|
+
const r = renderTask(BASE_TASK, { grade: GRADE });
|
|
627
|
+
await r.boot(); await settle();
|
|
628
|
+
const html = r.html();
|
|
629
|
+
const start = html.indexOf('id="task-grade"');
|
|
630
|
+
const end = html.indexOf('<p class="rec-sec__h">Actions');
|
|
631
|
+
assert.ok(start > 0 && end > start, 'the grade section renders, before the actions');
|
|
632
|
+
const sec = html.slice(start, end);
|
|
633
|
+
const axes = [...sec.matchAll(/<span class="tk-axis__n">([^<]+)<\/span><span class="rec-pill rec-pill--(\w+) rec-pill--sm">(\w+)<\/span>/g)].map((m) => [m[1], m[2], m[3]]);
|
|
634
|
+
assert.deepEqual(axes, [['narc', 'ok', 'pass'], ['quality', 'ok', 'pass'], ['hacker', 'ok', 'pass'], ['efficiency', 'ok', 'pass']],
|
|
635
|
+
'the four workers in the panel order, each a pass pill');
|
|
636
|
+
assert.match(sec, /<span class="tk-score__v">8\.5<small class="tk-score__of">\/10<\/small><\/span>/, 'the score out of ten');
|
|
637
|
+
assert.match(sec, /<span class="rec-pill rec-pill--ok">verdict pass<\/span>/, 'the verdict');
|
|
638
|
+
assert.match(sec, /<span class="tk-crumb">threshold 6 · panel v4\.0 · claude-sonnet-5<\/span>/, 'threshold, panel version, model');
|
|
639
|
+
assert.doesNotMatch(sec, /accent/, 'state never borrows the accent');
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
test('a failed or mixed panel wears warn and danger, never ok', async () => {
|
|
643
|
+
const grade = {
|
|
644
|
+
...GRADE, passed: false, score: '4.5',
|
|
645
|
+
signals: {
|
|
646
|
+
...GRADE.signals, panel_outcome: 'fail',
|
|
647
|
+
workers: [
|
|
648
|
+
{ kind: 'narc', verdict: 'pass', model: 'claude-sonnet-5' },
|
|
649
|
+
{ kind: 'quality', verdict: 'concerns', model: 'claude-sonnet-5' },
|
|
650
|
+
{ kind: 'hacker', verdict: 'fail', model: 'claude-sonnet-5' },
|
|
651
|
+
{ kind: 'efficiency', verdict: null, error: 'SUBAGENT_TIMEOUT', model: 'claude-sonnet-5' },
|
|
652
|
+
],
|
|
653
|
+
},
|
|
654
|
+
};
|
|
655
|
+
const r = renderTask(BASE_TASK, { grade });
|
|
656
|
+
await r.boot(); await settle();
|
|
657
|
+
const html = r.html();
|
|
658
|
+
assert.match(html, /tk-axis__n">quality<\/span><span class="rec-pill rec-pill--warn rec-pill--sm">concerns</, 'concerns is amber');
|
|
659
|
+
assert.match(html, /tk-axis__n">hacker<\/span><span class="rec-pill rec-pill--danger rec-pill--sm">fail</, 'fail is red');
|
|
660
|
+
assert.match(html, /tk-axis__n">efficiency<\/span><span class="rec-pill rec-pill--danger rec-pill--sm">error</, 'an errored worker says so, in red');
|
|
661
|
+
assert.match(html, /<span class="rec-pill rec-pill--danger">verdict fail<\/span>/);
|
|
662
|
+
assert.match(html, /tk-score__v">4\.5</);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test('no grade draws no grade panel at all; an unavailable panel is one honest line', async () => {
|
|
666
|
+
for (const opts of [{}, { grade: null }]) {
|
|
667
|
+
const r = renderTask(BASE_TASK, opts);
|
|
668
|
+
await r.boot(); await settle();
|
|
669
|
+
assert.ok(!r.html().includes('id="task-grade"'), 'nothing — no placeholder, no "no grade yet"');
|
|
670
|
+
assert.ok(!/rec-sec__h">Grade</.test(r.html()), 'not even the heading');
|
|
671
|
+
}
|
|
672
|
+
const r = renderTask(BASE_TASK, { grade: { passed: false, score: null, signals: { panel_outcome: 'unavailable', workers: [] } } });
|
|
673
|
+
await r.boot(); await settle();
|
|
674
|
+
const html = r.html();
|
|
675
|
+
assert.match(html, /id="task-grade"/);
|
|
676
|
+
assert.match(html, /could not run when this task was shipped/, 'an outage is data, and reads as one');
|
|
677
|
+
assert.ok(!html.includes('tk-axis'), 'no empty worker cells');
|
|
678
|
+
assert.ok(!html.slice(html.indexOf('id="task-grade"')).split('<p class="rec-sec__h">Actions')[0].includes('<span class="rec-pill'), 'and no verdict pill where none was reached');
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
test('the session log: when, who, how long, the outcome, what it paid, the handoff, the way to Sessions — newest first', async () => {
|
|
682
|
+
const older = { ...SESSION, id: '0', builder_id: '7', display_name: 'Someone Else', started_at: '2026-08-29T10:00:00Z', ended_at: '2026-08-29T12:05:00Z', outcome: 'partial', notes_md: '' };
|
|
683
|
+
const r = renderTask({ ...BASE_TASK, credits_awarded: 60 }, { sessionLogs: [SESSION, older] });
|
|
684
|
+
await r.boot(); await settle();
|
|
685
|
+
const html = r.html();
|
|
686
|
+
const start = html.indexOf('id="task-sessions"');
|
|
687
|
+
const end = html.indexOf('<p class="rec-sec__h">Actions');
|
|
688
|
+
assert.ok(start > 0 && end > start, 'the session log renders, before the actions');
|
|
689
|
+
const sec = html.slice(start, end);
|
|
690
|
+
assert.match(sec, /<p class="rec-sec__h">Session log <em>2 sessions<\/em><\/p>/);
|
|
691
|
+
const ledgers = sec.split('<div class="tk-ledger">').slice(1);
|
|
692
|
+
assert.equal(ledgers.length, 2);
|
|
693
|
+
assert.match(ledgers[0], /<span class="tk-lk">Session<\/span><span class="tk-lv">2026-08-30, <a class="rec-dep" href="\/profile\?id=95">Percy Jackson<\/a>, 37m<\/span>/,
|
|
694
|
+
'date, the builder as a link to their profile, the duration');
|
|
695
|
+
assert.match(ledgers[0], /<span class="rec-pill rec-pill--sm rec-pill--ok">shipped<\/span>/, 'the outcome in the state vocabulary');
|
|
696
|
+
assert.match(ledgers[0], /<span class="tk-lk">Credits awarded<\/span><span class="tk-lv">60<\/span>/, 'what the task paid, on the session that shipped it');
|
|
697
|
+
assert.match(ledgers[0], /<details class="tk-handoff"><summary>Read the handoff notes<\/summary><div class="rec-desc rec-md"><p><strong>What shipped:<\/strong><\/p>/,
|
|
698
|
+
'the handoff renders through the reader, behind a disclosure');
|
|
699
|
+
assert.match(ledgers[0], /<a class="rec-dep" href="\/sessions">Open in Sessions<\/a>/, 'the way to the full log');
|
|
700
|
+
assert.match(ledgers[1], /Someone Else<\/a>, 2h 05m/, 'the earlier session follows, with its own duration');
|
|
701
|
+
assert.match(ledgers[1], /rec-pill--sm rec-pill--warn">partial</, 'partial is amber');
|
|
702
|
+
assert.ok(!ledgers[1].includes('Credits awarded'), 'no pay row on a session that did not ship');
|
|
703
|
+
assert.ok(!ledgers[1].includes('tk-handoff'), 'no handoff row without a note');
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
test('no session log draws nothing', async () => {
|
|
707
|
+
for (const opts of [{}, { sessionLogs: null }, { sessionLogs: [] }]) {
|
|
708
|
+
const r = renderTask(BASE_TASK, opts);
|
|
709
|
+
await r.boot(); await settle();
|
|
710
|
+
assert.ok(!r.html().includes('id="task-sessions"'), 'nothing at all');
|
|
711
|
+
assert.ok(!/Session log/.test(r.html()));
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
test('the claim ledger lists the earlier claims under the current one, and the "most recent only" caveat is gone', async () => {
|
|
716
|
+
const last = { builder_id: '95', github_login: 'jaxri', claimed_at: '2026-08-30T17:41:00Z', released_at: '2026-08-30T18:18:00Z', outcome: 'shipped' };
|
|
717
|
+
const claims = [
|
|
718
|
+
{ ...last, claim_id: '2', display_name: 'Percy Jackson' },
|
|
719
|
+
{ claim_id: '1', builder_id: '7', github_login: 'other', display_name: 'Someone Else', claimed_at: '2026-08-29T10:00:00Z', released_at: '2026-08-29T12:05:00Z', outcome: 'abandoned' },
|
|
720
|
+
];
|
|
721
|
+
const r = renderTask(BASE_TASK, { lastClaim: last, claims });
|
|
722
|
+
await r.boot(); await settle();
|
|
723
|
+
const html = r.html();
|
|
724
|
+
assert.match(html, /<p class="rec-sec__h">Claim <em>2 claims<\/em><\/p>/);
|
|
725
|
+
assert.match(html, /Last claimed by <b>jaxri<\/b>/);
|
|
726
|
+
assert.match(html, /<ul class="rec-files tk-claims"><li><a class="rec-dep" href="\/profile\?id=7">Someone Else<\/a> · 2026-08-29 · abandoned<\/li><\/ul>/,
|
|
727
|
+
'the earlier claim: who, when, how it ended');
|
|
728
|
+
assert.ok(!html.includes('never the full ledger'), 'the caveat no longer holds, so it is gone');
|
|
729
|
+
// Without the ledger on the read (an older stub), the section reads as it did.
|
|
730
|
+
const r2 = renderTask(BASE_TASK, { lastClaim: last });
|
|
731
|
+
await r2.boot(); await settle();
|
|
732
|
+
assert.match(r2.html(), /<p class="rec-sec__h">Claim<\/p>/);
|
|
733
|
+
assert.ok(!r2.html().includes('tk-claims'));
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
test('task.css declares the new shapes with tokens only — no literal colour, no accent', () => {
|
|
737
|
+
const css = decomment(read('task.css'));
|
|
738
|
+
for (const sel of ['.tk-axes', '.tk-axis__n', '.tk-score__v', '.tk-crumb', '.tk-ledger', '.tk-lrow', '.tk-lk', '.tk-lv', '.tk-handoff > summary']) {
|
|
739
|
+
assert.ok(css.includes(sel), `${sel} is styled`);
|
|
740
|
+
}
|
|
741
|
+
assert.doesNotMatch(css, /#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(/, 'every colour is a token');
|
|
742
|
+
assert.doesNotMatch(css.slice(css.indexOf('.tk-axes')), /--accent/, 'the grade and session shapes never borrow the accent (the lifecycle rail above them lights its one beat with it, by design)');
|
|
743
|
+
assert.match(css, /\.tk-handoff > summary \{[^}]*min-height: 24px/, 'the disclosure is a 24px control');
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
test('the mocks no longer mark the two regions proposed — they are built', () => {
|
|
747
|
+
for (const v of ['v1', 'v3']) {
|
|
748
|
+
const mock = fs.readFileSync(path.join(ROOT, 'docs', 'design', 'mocks', 'hall', 'task', `${v}.html`), 'utf8');
|
|
749
|
+
assert.ok(!/class="(panel|sec) proposed"/.test(mock), `${v}: no dashed proposed panel`);
|
|
750
|
+
assert.ok(!mock.includes('<em>proposed</em>'), `${v}: no proposed eyebrow`);
|
|
751
|
+
}
|
|
752
|
+
});
|