@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.
@@ -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,
@@ -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.492",
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.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.491",
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.491'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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
- function renderTask(task, { lastClaim = null, viewer = null, goal = null, status = 200, search = '', referrer = '', withReader = true } = {}) {
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
- if (/\/tasks\/\d+/.test(p)) return { status, ok: status === 200, data: status === 200 ? { task, last_claim: lastClaim } : null };
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 &lt;b&gt;text&lt;\/b&gt;<\/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
+ });